diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 9dfb9d3..9aad91d 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -107,6 +107,8 @@ dependencies {
implementation("androidx.media3:media3-ui:1.4.1")
implementation("androidx.media3:media3-datasource:1.4.1")
implementation("androidx.media3:media3-datasource-okhttp:1.4.1")
+ implementation("androidx.media3:media3-effect:1.4.1")
+ implementation("androidx.media3:media3-transformer:1.4.1")
implementation("androidx.camera:camera-core:1.4.2")
implementation("androidx.camera:camera-camera2:1.4.2")
implementation("androidx.camera:camera-lifecycle:1.4.2")
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index a0946b9..e101439 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -5,6 +5,9 @@
+
(null)
private var pendingVerifyEmailToken by mutableStateOf(null)
private var pendingResetPasswordToken by mutableStateOf(null)
@@ -70,6 +74,7 @@ class MainActivity : AppCompatActivity() {
pendingNotificationChatId = notificationPayload?.first
pendingNotificationMessageId = notificationPayload?.second
notificationPayload?.first?.let(notificationDispatcher::clearChatNotifications)
+ handleRealtimeEventsUseCase.start()
enableEdgeToEdge()
setContent {
MessengerTheme {
diff --git a/android/app/src/main/java/ru/daemonlord/messenger/core/notifications/NotificationDispatcher.kt b/android/app/src/main/java/ru/daemonlord/messenger/core/notifications/NotificationDispatcher.kt
index 3a81c67..158210b 100644
--- a/android/app/src/main/java/ru/daemonlord/messenger/core/notifications/NotificationDispatcher.kt
+++ b/android/app/src/main/java/ru/daemonlord/messenger/core/notifications/NotificationDispatcher.kt
@@ -1,10 +1,14 @@
package ru.daemonlord.messenger.core.notifications
+import android.Manifest
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
+import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import ru.daemonlord.messenger.MainActivity
import javax.inject.Inject
@@ -73,7 +77,7 @@ class NotificationDispatcher @Inject constructor(
.build()
val manager = NotificationManagerCompat.from(context)
- manager.notify(chatNotificationId(payload.chatId), notification)
+ manager.notifySafely(chatNotificationId(payload.chatId), notification)
showSummaryNotification(manager)
}
@@ -82,14 +86,14 @@ class NotificationDispatcher @Inject constructor(
synchronized(chatStates) {
chatStates.remove(chatId)
}
- manager.cancel(chatNotificationId(chatId))
+ manager.cancelSafely(chatNotificationId(chatId))
showSummaryNotification(manager)
}
private fun showSummaryNotification(manager: NotificationManagerCompat) {
val snapshot = synchronized(chatStates) { chatStates.values.toList() }
if (snapshot.isEmpty()) {
- manager.cancel(SUMMARY_NOTIFICATION_ID)
+ manager.cancelSafely(SUMMARY_NOTIFICATION_ID)
return
}
val totalUnread = snapshot.sumOf { it.unreadCount }
@@ -114,7 +118,24 @@ class NotificationDispatcher @Inject constructor(
.setGroupSummary(true)
.setAutoCancel(true)
.build()
- manager.notify(SUMMARY_NOTIFICATION_ID, summary)
+ manager.notifySafely(SUMMARY_NOTIFICATION_ID, summary)
+ }
+
+ private fun NotificationManagerCompat.notifySafely(id: Int, notification: android.app.Notification) {
+ if (!canPostNotifications()) return
+ runCatching { notify(id, notification) }
+ }
+
+ private fun NotificationManagerCompat.cancelSafely(id: Int) {
+ runCatching { cancel(id) }
+ }
+
+ private fun canPostNotifications(): Boolean {
+ return Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
+ ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.POST_NOTIFICATIONS,
+ ) == PackageManager.PERMISSION_GRANTED
}
private fun chatNotificationId(chatId: Long): Int {
diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/media/repository/NetworkMediaRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/media/repository/NetworkMediaRepository.kt
index 0849620..1c7e18a 100644
--- a/android/app/src/main/java/ru/daemonlord/messenger/data/media/repository/NetworkMediaRepository.kt
+++ b/android/app/src/main/java/ru/daemonlord/messenger/data/media/repository/NetworkMediaRepository.kt
@@ -2,8 +2,8 @@ package ru.daemonlord.messenger.data.media.repository
import android.graphics.Bitmap
import android.graphics.BitmapFactory
-import android.graphics.Canvas
-import android.graphics.Movie
+import android.graphics.ImageDecoder
+import android.os.Build
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull
@@ -166,14 +166,8 @@ class NetworkMediaRepository @Inject constructor(
fileName: String,
bytes: ByteArray,
): UploadPayload? {
- val movie = runCatching { Movie.decodeByteArray(bytes, 0, bytes.size) }.getOrNull() ?: return null
- val width = movie.width().coerceAtLeast(1)
- val height = movie.height().coerceAtLeast(1)
- val bitmap = runCatching { Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) }.getOrNull() ?: return null
+ val bitmap = decodeGifFirstFrame(bytes) ?: return null
return try {
- val canvas = Canvas(bitmap)
- movie.setTime(0)
- movie.draw(canvas, 0f, 0f)
val output = ByteArrayOutputStream()
val compressed = bitmap.compress(Bitmap.CompressFormat.PNG, 100, output)
if (!compressed) return null
@@ -189,4 +183,17 @@ class NetworkMediaRepository @Inject constructor(
bitmap.recycle()
}
}
+
+ private fun decodeGifFirstFrame(bytes: ByteArray): Bitmap? {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ val decoded = runCatching {
+ val source = ImageDecoder.createSource(java.nio.ByteBuffer.wrap(bytes))
+ ImageDecoder.decodeBitmap(source) { decoder, _, _ ->
+ decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
+ }
+ }.getOrNull()
+ if (decoded != null) return decoded
+ }
+ return runCatching { BitmapFactory.decodeByteArray(bytes, 0, bytes.size) }.getOrNull()
+ }
}
diff --git a/android/app/src/main/java/ru/daemonlord/messenger/di/MediaCacheModule.kt b/android/app/src/main/java/ru/daemonlord/messenger/di/MediaCacheModule.kt
index aea98c3..604c5d8 100644
--- a/android/app/src/main/java/ru/daemonlord/messenger/di/MediaCacheModule.kt
+++ b/android/app/src/main/java/ru/daemonlord/messenger/di/MediaCacheModule.kt
@@ -1,6 +1,7 @@
package ru.daemonlord.messenger.di
import android.content.Context
+import androidx.media3.common.util.UnstableApi
import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.cache.Cache
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
@@ -15,10 +16,12 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
+@UnstableApi
object MediaCacheModule {
@Provides
@Singleton
+ @UnstableApi
fun provideMediaCache(
@ApplicationContext context: Context,
): Cache {
@@ -34,4 +37,3 @@ object MediaCacheModule {
)
}
}
-
diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/usecase/HandleRealtimeEventsUseCase.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/usecase/HandleRealtimeEventsUseCase.kt
index 6b90310..580f9a2 100644
--- a/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/usecase/HandleRealtimeEventsUseCase.kt
+++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/usecase/HandleRealtimeEventsUseCase.kt
@@ -14,6 +14,7 @@ import ru.daemonlord.messenger.core.token.TokenRepository
import ru.daemonlord.messenger.data.message.local.dao.MessageDao
import ru.daemonlord.messenger.data.message.local.entity.MessageEntity
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
+import ru.daemonlord.messenger.domain.message.repository.MessageRepository
import ru.daemonlord.messenger.domain.notifications.repository.NotificationSettingsRepository
import ru.daemonlord.messenger.domain.notifications.usecase.ShouldShowMessageNotificationUseCase
import ru.daemonlord.messenger.domain.realtime.RealtimeManager
@@ -27,6 +28,7 @@ class HandleRealtimeEventsUseCase @Inject constructor(
private val chatRepository: ChatRepository,
private val chatDao: ChatDao,
private val messageDao: MessageDao,
+ private val messageRepository: MessageRepository,
private val notificationDispatcher: NotificationDispatcher,
private val activeChatTracker: ActiveChatTracker,
private val tokenRepository: TokenRepository,
@@ -50,6 +52,8 @@ class HandleRealtimeEventsUseCase @Inject constructor(
is RealtimeEvent.ReceiveMessage -> {
val activeChatId = activeChatTracker.activeChatId.value
+ val lastMessagePreview = event.text?.takeIf { it.isNotBlank() }
+ ?: fallbackMessagePreview(event.type)
messageDao.upsertMessages(
listOf(
MessageEntity(
@@ -75,16 +79,18 @@ class HandleRealtimeEventsUseCase @Inject constructor(
)
chatDao.updateLastMessage(
chatId = event.chatId,
- lastMessageText = event.text,
+ lastMessageText = lastMessagePreview,
lastMessageType = event.type,
lastMessageCreatedAt = event.createdAt,
updatedSortAt = event.createdAt,
)
if (activeChatId == event.chatId) {
chatDao.markChatRead(chatId = event.chatId)
+ messageRepository.syncRecentMessages(chatId = event.chatId)
} else {
chatDao.incrementUnread(chatId = event.chatId)
}
+ chatRepository.refreshChat(chatId = event.chatId)
val activeUserId = tokenRepository.getActiveUserId()
val myUsername = activeUserId?.let { userId ->
tokenRepository.getAccounts()
@@ -142,11 +148,15 @@ class HandleRealtimeEventsUseCase @Inject constructor(
)
chatDao.updateLastMessage(
chatId = event.chatId,
- lastMessageText = event.text,
+ lastMessageText = event.text?.takeIf { it.isNotBlank() } ?: fallbackMessagePreview(event.type),
lastMessageType = event.type,
lastMessageCreatedAt = event.updatedAt,
updatedSortAt = event.updatedAt,
)
+ if (activeChatTracker.activeChatId.value == event.chatId) {
+ messageRepository.syncRecentMessages(chatId = event.chatId)
+ }
+ chatRepository.refreshChat(chatId = event.chatId)
}
is RealtimeEvent.MessageDeleted -> {
@@ -156,6 +166,7 @@ class HandleRealtimeEventsUseCase @Inject constructor(
}
is RealtimeEvent.ChatUpdated -> {
+ chatRepository.refreshChat(chatId = event.chatId)
chatRepository.refreshChats(archived = false)
chatRepository.refreshChats(archived = true)
}
@@ -209,4 +220,16 @@ class HandleRealtimeEventsUseCase @Inject constructor(
collectionJob = null
realtimeManager.disconnect()
}
+
+ private fun fallbackMessagePreview(type: String?): String {
+ return when (type?.lowercase()) {
+ "image" -> "Photo"
+ "video" -> "Video"
+ "audio" -> "Audio"
+ "voice" -> "Voice message"
+ "file" -> "File"
+ "circle_video" -> "Video message"
+ else -> "New message"
+ }
+ }
}
diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt
index e1b020e..6686ea3 100644
--- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt
+++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt
@@ -1,26 +1,29 @@
package ru.daemonlord.messenger.ui.chat
import android.Manifest
-import android.app.Activity
import android.graphics.Bitmap
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
-import android.media.AudioAttributes
+import android.os.Build
import android.media.MediaMetadataRetriever
-import android.media.MediaPlayer
import android.net.Uri
import android.provider.OpenableColumns
import android.widget.Toast
-import android.widget.VideoView
+import androidx.activity.ComponentActivity
+import androidx.activity.SystemBarStyle
import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.gestures.detectTransformGestures
+import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
+import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.Arrangement
@@ -38,11 +41,12 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.aspectRatio
-import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
@@ -89,6 +93,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
@@ -96,11 +101,13 @@ import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
+import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.toArgb
@@ -119,7 +126,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
-import androidx.core.view.WindowCompat
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Forward
import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -186,9 +192,12 @@ import java.net.URLEncoder
import kotlinx.coroutines.delay
import kotlin.math.roundToInt
import androidx.camera.core.CameraSelector
+import androidx.camera.core.ImageCapture
+import androidx.camera.core.ImageCaptureException
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.video.FileOutputOptions
+import androidx.camera.video.FallbackStrategy
import androidx.camera.video.PendingRecording
import androidx.camera.video.Quality
import androidx.camera.video.QualitySelector
@@ -199,14 +208,28 @@ import androidx.camera.video.VideoRecordEvent
import androidx.camera.view.PreviewView
import androidx.compose.ui.window.Dialog
import androidx.media3.common.MediaItem
+import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.effect.Presentation
import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.ui.DefaultTimeBar
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
+import androidx.media3.transformer.EditedMediaItem
+import androidx.media3.transformer.Effects
+import androidx.media3.transformer.ExportException
+import androidx.media3.transformer.ExportResult
+import androidx.media3.transformer.Transformer
+import androidx.media3.transformer.Composition
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+import kotlinx.coroutines.suspendCancellableCoroutine
@Composable
fun ChatRoute(
onBack: () -> Unit,
+ showBackButton: Boolean = true,
viewModel: ChatViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
@@ -214,6 +237,7 @@ fun ChatRoute(
val lifecycleOwner = LocalLifecycleOwner.current
val voiceRecorder = remember(context) { VoiceRecorder(context) }
var showCircleRecorder by remember { mutableStateOf(false) }
+ var cameraCaptureMode by remember { mutableStateOf(null) }
var hasAudioPermission by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(
@@ -232,6 +256,7 @@ fun ChatRoute(
}
var startRecordingAfterPermissionGrant by remember { mutableStateOf(false) }
var openCircleAfterPermissionGrant by remember { mutableStateOf(false) }
+ var pendingCameraCaptureMode by remember { mutableStateOf(null) }
val startVoiceRecording: () -> Unit = {
val started = voiceRecorder.start()
if (started) {
@@ -261,6 +286,11 @@ fun ChatRoute(
showCircleRecorder = true
}
openCircleAfterPermissionGrant = false
+ val pendingMode = pendingCameraCaptureMode
+ if (pendingMode != null && hasCameraPermission && (pendingMode == CameraCaptureMode.Photo || hasAudioPermission)) {
+ cameraCaptureMode = pendingMode
+ }
+ pendingCameraCaptureMode = null
}
DisposableEffect(voiceRecorder) {
onDispose {
@@ -278,42 +308,12 @@ fun ChatRoute(
bytes = picked.bytes,
)
}
- val takePhotoLauncher = rememberLauncherForActivityResult(
- contract = ActivityResultContracts.TakePicturePreview(),
- ) { bitmap ->
- val picked = bitmap?.toJpegPayload() ?: return@rememberLauncherForActivityResult
- viewModel.onMediaPicked(
- fileName = "camera_photo_${System.currentTimeMillis()}.jpg",
- mimeType = picked.mimeType,
- bytes = picked.bytes,
- )
- }
- var pendingCaptureVideoUri by remember { mutableStateOf(null) }
- var pendingCaptureVideoFile by remember { mutableStateOf(null) }
- val captureVideoLauncher = rememberLauncherForActivityResult(
- contract = ActivityResultContracts.CaptureVideo(),
- ) { success ->
- val uri = pendingCaptureVideoUri
- val file = pendingCaptureVideoFile
- pendingCaptureVideoUri = null
- pendingCaptureVideoFile = null
- if (success && uri != null) {
- val picked = uri.readMediaPayload(context)
- if (picked != null) {
- viewModel.onMediaPicked(
- fileName = "camera_video_${System.currentTimeMillis()}.mp4",
- mimeType = if (picked.mimeType.startsWith("video/")) picked.mimeType else "video/mp4",
- bytes = picked.bytes,
- )
- }
- }
- runCatching { file?.delete() }
- }
ChatScreen(
state = state,
actions = ChatScreenActions(
onBack = onBack,
+ showBackButton = showBackButton,
onInputChanged = viewModel::onInputChanged,
onSendClick = viewModel::onSendClick,
onSelectMessage = viewModel::onSelectMessage,
@@ -330,17 +330,26 @@ fun ChatRoute(
onCancelComposeAction = viewModel::onCancelComposeAction,
onLoadMore = viewModel::loadMore,
onPickMedia = { pickMediaLauncher.launch("*/*") },
- onCapturePhoto = { takePhotoLauncher.launch(null) },
+ onCapturePhoto = {
+ if (hasCameraPermission) {
+ cameraCaptureMode = CameraCaptureMode.Photo
+ } else {
+ pendingCameraCaptureMode = CameraCaptureMode.Photo
+ circlePermissionLauncher.launch(arrayOf(Manifest.permission.CAMERA))
+ }
+ },
onCaptureVideo = {
- val file = createTempCaptureFile(context = context, prefix = "camera_video_", suffix = ".mp4")
- val uri = FileProvider.getUriForFile(
- context,
- "${context.packageName}.fileprovider",
- file,
- )
- pendingCaptureVideoFile = file
- pendingCaptureVideoUri = uri
- captureVideoLauncher.launch(uri)
+ if (hasCameraPermission && hasAudioPermission) {
+ cameraCaptureMode = CameraCaptureMode.Video
+ } else {
+ pendingCameraCaptureMode = CameraCaptureMode.Video
+ circlePermissionLauncher.launch(
+ arrayOf(
+ Manifest.permission.CAMERA,
+ Manifest.permission.RECORD_AUDIO,
+ ),
+ )
+ }
},
onCaptureCircleVideo = {
if (hasCameraPermission) {
@@ -423,10 +432,27 @@ fun ChatRoute(
},
)
}
+ val captureMode = cameraCaptureMode
+ if (captureMode != null) {
+ CameraCaptureDialog(
+ lifecycleOwner = lifecycleOwner,
+ mode = captureMode,
+ onDismiss = { cameraCaptureMode = null },
+ onSend = { payload ->
+ viewModel.onMediaPicked(
+ fileName = payload.fileName,
+ mimeType = payload.mimeType,
+ bytes = payload.bytes,
+ )
+ cameraCaptureMode = null
+ },
+ )
+ }
}
private data class ChatScreenActions(
val onBack: () -> Unit,
+ val showBackButton: Boolean,
val onInputChanged: (String) -> Unit,
val onSendClick: () -> Unit,
val onSelectMessage: (MessageItem?) -> Unit,
@@ -474,6 +500,7 @@ private fun ChatScreen(
actions: ChatScreenActions,
) {
val onBack = actions.onBack
+ val showBackButton = actions.showBackButton
val onInputChanged = actions.onInputChanged
val onSendClick = actions.onSendClick
val onSelectMessage = actions.onSelectMessage
@@ -516,33 +543,7 @@ private fun ChatScreen(
val view = LocalView.current
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
- val allImageUrls = remember(state.messages) {
- state.messages
- .flatMap { message ->
- val urls = message.attachments
- .map { it.fileUrl to it.fileType.lowercase() }
- .filter { (_, type) ->
- type.startsWith("image/") &&
- !type.contains("gif") &&
- !type.contains("webp")
- }
- .map { (url, _) -> url }
- .toMutableList()
- val legacyUrl = message.text?.trim()
- if (
- urls.isEmpty() &&
- message.type.equals("image", ignoreCase = true) &&
- !legacyUrl.isNullOrBlank() &&
- legacyUrl.startsWith("http", ignoreCase = true) &&
- !isGifLikeUrl(legacyUrl) &&
- !isStickerLikeUrl(legacyUrl)
- ) {
- urls += legacyUrl
- }
- urls
- }
- .distinct()
- }
+ val allViewerMediaItems = remember(state.messages) { buildChatViewerMediaItems(state.messages) }
val isChannelChat = state.chatType.equals("channel", ignoreCase = true)
val isPrivateChat = state.chatType.equals("private", ignoreCase = true)
val canShowMembersTab = state.chatType.equals("group", ignoreCase = true) ||
@@ -579,8 +580,7 @@ private fun ChatScreen(
}
}
var didInitialAutoScroll by remember(state.chatId) { mutableStateOf(false) }
- var viewerImageIndex by remember { mutableStateOf(null) }
- var viewerVideoUrl by remember { mutableStateOf(null) }
+ var viewerMediaIndex by remember { mutableStateOf(null) }
var dismissedPinnedMessageId by remember { mutableStateOf(null) }
var topAudioStrip by remember { mutableStateOf(null) }
var forceStopAudioSourceId by remember { mutableStateOf(null) }
@@ -613,6 +613,10 @@ private fun ChatScreen(
val adaptiveHorizontalPadding = if (isTabletLayout) 72.dp else 0.dp
val chatInfoEntries = remember(state.messages, context) { buildChatInfoEntries(state.messages, context) }
val giphyApiKey = remember { BuildConfig.GIPHY_API_KEY.trim() }
+ val openViewerForMedia: (String, ChatViewerMediaType) -> Unit = { url, type ->
+ val index = allViewerMediaItems.indexOfFirst { it.url == url && it.type == type }
+ viewerMediaIndex = if (index >= 0) index else null
+ }
val firstUnreadIncomingMessageId = remember(state.messages, state.chatUnreadCount) {
val unread = state.chatUnreadCount.coerceAtLeast(0)
if (unread <= 0) {
@@ -655,10 +659,15 @@ private fun ChatScreen(
}
if (!view.isInEditMode) {
SideEffect {
- val window = (context as? Activity)?.window ?: return@SideEffect
- window.statusBarColor = chatTopBarColor.toArgb()
- WindowCompat.getInsetsController(window, view)?.isAppearanceLightStatusBars =
- chatTopBarColor.luminance() > 0.5f
+ val activity = context as? ComponentActivity ?: return@SideEffect
+ val scrim = chatTopBarColor.toArgb()
+ activity.enableEdgeToEdge(
+ statusBarStyle = if (chatTopBarColor.luminance() > 0.5f) {
+ SystemBarStyle.light(scrim, scrim)
+ } else {
+ SystemBarStyle.dark(scrim)
+ },
+ )
}
}
@@ -823,11 +832,13 @@ private fun ChatScreen(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
- IconButton(onClick = onBack) {
- Icon(
- imageVector = Icons.AutoMirrored.Filled.ArrowBack,
- contentDescription = "Back",
- )
+ if (showBackButton) {
+ IconButton(onClick = onBack) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = "Back",
+ )
+ }
}
Row(
modifier = Modifier
@@ -1141,11 +1152,10 @@ private fun ChatScreen(
replyAuthorByMessageId = replyAuthorByMessageId,
reactions = state.reactionByMessageId[message.id].orEmpty(),
onAttachmentImageClick = { imageUrl ->
- val idx = allImageUrls.indexOf(imageUrl)
- viewerImageIndex = if (idx >= 0) idx else null
+ openViewerForMedia(imageUrl, ChatViewerMediaType.Image)
},
onAttachmentVideoClick = { videoUrl ->
- viewerVideoUrl = videoUrl
+ openViewerForMedia(videoUrl, ChatViewerMediaType.Video)
},
onAudioPlaybackChanged = { playback ->
if (playback.isPlaying) {
@@ -1509,11 +1519,10 @@ private fun ChatScreen(
tab = chatInfoTab,
entries = chatInfoEntries,
onAttachmentImageClick = { imageUrl ->
- val idx = allImageUrls.indexOf(imageUrl)
- viewerImageIndex = if (idx >= 0) idx else null
+ openViewerForMedia(imageUrl, ChatViewerMediaType.Image)
},
onAttachmentVideoClick = { videoUrl ->
- viewerVideoUrl = videoUrl
+ openViewerForMedia(videoUrl, ChatViewerMediaType.Video)
},
onAudioPlaybackChanged = { playback ->
if (playback.isPlaying) {
@@ -1565,11 +1574,13 @@ private fun ChatScreen(
val url = entry.resourceUrl.orEmpty()
if (url.isBlank()) return@ChatInfoTabContent
if (entry.previewIsVideo) {
- viewerVideoUrl = url
+ openViewerForMedia(url, ChatViewerMediaType.Video)
} else {
- val index = allImageUrls.indexOf(url)
+ val index = allViewerMediaItems.indexOfFirst {
+ it.url == url && it.type == ChatViewerMediaType.Image
+ }
if (index >= 0) {
- viewerImageIndex = index
+ viewerMediaIndex = index
} else {
openUrlExternally(context, url)
}
@@ -1578,11 +1589,18 @@ private fun ChatScreen(
ChatInfoEntryType.Link -> {
entry.resourceUrl?.let { openUrlExternally(context, it) }
}
- ChatInfoEntryType.File,
- ChatInfoEntryType.Voice,
- -> {
+ ChatInfoEntryType.File -> {
entry.resourceUrl?.let { openUrlExternally(context, it) }
}
+ ChatInfoEntryType.Voice -> {
+ val url = entry.resourceUrl.orEmpty()
+ if (url.isBlank()) return@ChatInfoTabContent
+ if (entry.previewIsVideo) {
+ openViewerForMedia(url, ChatViewerMediaType.Video)
+ } else {
+ openUrlExternally(context, url)
+ }
+ }
}
},
)
@@ -1787,64 +1805,30 @@ private fun ChatScreen(
shape = CircleShape,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f),
) {
- IconButton(
- onClick = { useCircleRecording = !useCircleRecording },
- enabled = state.canSendMessages && !state.isUploadingMedia && !state.isRecordingVoice,
- ) {
- Icon(
- imageVector = if (useCircleRecording) {
- Icons.Filled.RadioButtonChecked
- } else {
- Icons.Filled.Mic
- },
- contentDescription = if (useCircleRecording) {
- "Circle mode enabled"
- } else {
- "Voice mode enabled"
- },
- )
- }
- }
- val canSend = state.canSendMessages &&
+ val canSend = state.canSendMessages &&
!state.isSending &&
!state.isUploadingMedia &&
composerValue.text.isNotBlank()
- if (canSend) {
- Surface(
- shape = CircleShape,
- color = MaterialTheme.colorScheme.primary.copy(alpha = 0.18f),
- ) {
+ if (canSend) {
IconButton(
onClick = onSendClick,
enabled = state.canSendMessages && !state.isUploadingMedia,
) {
Icon(imageVector = Icons.AutoMirrored.Filled.Send, contentDescription = "Send")
}
- }
- } else if (useCircleRecording) {
- Surface(
- shape = CircleShape,
- color = MaterialTheme.colorScheme.primary.copy(alpha = 0.18f),
- ) {
- IconButton(
- onClick = onCaptureCircleVideo,
+ } else {
+ UnifiedRecordButton(
enabled = state.canSendMessages && !state.isUploadingMedia,
- ) {
- Icon(
- imageVector = Icons.Filled.RadioButtonChecked,
- contentDescription = "Record circle video",
- )
- }
+ isLocked = state.isVoiceLocked,
+ useCircleRecording = useCircleRecording,
+ onToggleMode = { useCircleRecording = !useCircleRecording },
+ onStartVoice = onVoiceRecordStart,
+ onStartCircle = onCaptureCircleVideo,
+ onLock = onVoiceRecordLock,
+ onCancel = onVoiceRecordCancel,
+ onRelease = onVoiceRecordSend,
+ )
}
- } else {
- VoiceHoldToRecordButton(
- enabled = state.canSendMessages && !state.isUploadingMedia,
- isLocked = state.isVoiceLocked,
- onStart = onVoiceRecordStart,
- onLock = onVoiceRecordLock,
- onCancel = onVoiceRecordCancel,
- onRelease = onVoiceRecordSend,
- )
}
}
}
@@ -2019,84 +2003,12 @@ private fun ChatScreen(
)
}
- if (viewerImageIndex != null) {
- val currentIndex = viewerImageIndex ?: 0
- val currentUrl = allImageUrls.getOrNull(currentIndex)
- Surface(
- modifier = Modifier
- .fillMaxSize()
- .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.7f))
- .clickable { },
- ) {
- Column(modifier = Modifier.fillMaxSize()) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 12.dp, vertical = 10.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically,
- ) {
- IconButton(onClick = { viewerImageIndex = null }) {
- Icon(imageVector = Icons.Filled.Close, contentDescription = "Close viewer")
- }
- Text(
- text = "${currentIndex + 1}/${allImageUrls.size.coerceAtLeast(1)}",
- style = MaterialTheme.typography.titleSmall,
- )
- Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
- IconButton(onClick = {}, enabled = false) {
- Icon(imageVector = Icons.AutoMirrored.Filled.Forward, contentDescription = "Forward image")
- }
- IconButton(onClick = {}, enabled = false) {
- Icon(imageVector = Icons.Filled.DeleteOutline, contentDescription = "Delete image")
- }
- }
- }
- Box(
- modifier = Modifier.weight(1f),
- contentAlignment = Alignment.Center,
- ) {
- if (currentUrl != null) {
- AsyncImage(
- model = currentUrl,
- contentDescription = "Attachment",
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
- contentScale = ContentScale.Fit,
- )
- }
- }
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp, vertical = 10.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
- Button(
- onClick = {
- viewerImageIndex = if (allImageUrls.isEmpty()) null else {
- (currentIndex - 1).coerceAtLeast(0)
- }
- },
- enabled = currentIndex > 0,
- ) { Text(stringResource(id = R.string.chat_search_prev)) }
- Button(
- onClick = {
- viewerImageIndex = if (allImageUrls.isEmpty()) null else {
- (currentIndex + 1).coerceAtMost(allImageUrls.lastIndex)
- }
- },
- enabled = currentIndex < allImageUrls.lastIndex,
- ) { Text(stringResource(id = R.string.chat_search_next)) }
- }
- }
- }
- }
- if (viewerVideoUrl != null) {
- VideoViewerOverlay(
- videoUrl = viewerVideoUrl.orEmpty(),
- onDismiss = { viewerVideoUrl = null },
+ if (viewerMediaIndex != null && allViewerMediaItems.isNotEmpty()) {
+ ChatMediaViewerOverlay(
+ items = allViewerMediaItems,
+ initialIndex = (viewerMediaIndex ?: 0).coerceIn(0, allViewerMediaItems.lastIndex.coerceAtLeast(0)),
+ onDismiss = { viewerMediaIndex = null },
+ onPageChanged = { viewerMediaIndex = it },
)
}
if (showEmojiPicker) {
@@ -2121,40 +2033,32 @@ private fun ChatScreen(
}
}
+private enum class ChatViewerMediaType {
+ Image,
+ Video,
+}
+
+private data class ChatViewerMediaItem(
+ val url: String,
+ val type: ChatViewerMediaType,
+)
+
@Composable
-private fun VideoViewerOverlay(
- videoUrl: String,
+private fun ChatMediaViewerOverlay(
+ items: List,
+ initialIndex: Int,
onDismiss: () -> Unit,
+ onPageChanged: (Int) -> Unit,
) {
- var videoViewRef by remember(videoUrl) { mutableStateOf(null) }
- var videoPrepared by remember(videoUrl) { mutableStateOf(false) }
- var videoDurationMs by remember(videoUrl) { mutableStateOf(0) }
- var videoPositionMs by remember(videoUrl) { mutableStateOf(0) }
- var videoPlaying by remember(videoUrl) { mutableStateOf(true) }
- var isVideoSeeking by remember(videoUrl) { mutableStateOf(false) }
- var videoSeekFraction by remember(videoUrl) { mutableStateOf(0f) }
-
- DisposableEffect(videoUrl) {
- onDispose {
- runCatching { videoViewRef?.stopPlayback() }
- videoViewRef = null
- }
- }
-
- LaunchedEffect(videoPlaying, videoPrepared, isVideoSeeking, videoUrl) {
- if (!videoPlaying || !videoPrepared || isVideoSeeking) return@LaunchedEffect
- while (videoPlaying && !isVideoSeeking) {
- videoPositionMs = runCatching { videoViewRef?.currentPosition ?: videoPositionMs }
- .getOrDefault(videoPositionMs)
- delay(250)
- }
+ val pagerState = rememberPagerState(initialPage = initialIndex.coerceIn(0, items.lastIndex.coerceAtLeast(0))) {
+ items.size
}
Surface(
modifier = Modifier
.fillMaxSize()
- .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.78f)),
- color = Color.Black.copy(alpha = 0.9f),
+ .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.82f)),
+ color = Color.Black.copy(alpha = 0.94f),
) {
Column(
modifier = Modifier.fillMaxSize(),
@@ -2164,116 +2068,362 @@ private fun VideoViewerOverlay(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp),
- horizontalArrangement = Arrangement.End,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onDismiss) {
- Icon(imageVector = Icons.Filled.Close, contentDescription = "Close video viewer")
+ Icon(imageVector = Icons.Filled.Close, contentDescription = "Close viewer", tint = Color.White)
}
- }
- AndroidView(
- factory = { context ->
- VideoView(context).apply {
- setVideoPath(videoUrl)
- videoViewRef = this
- setOnPreparedListener { player ->
- player.isLooping = false
- videoPrepared = true
- videoDurationMs = runCatching { duration }.getOrDefault(player.duration.coerceAtLeast(0))
- start()
- videoPlaying = true
- }
- setOnCompletionListener {
- videoPlaying = false
- videoPositionMs = videoDurationMs
- }
- }
- },
- update = { view ->
- videoViewRef = view
- if (videoPlaying && videoPrepared && !isVideoSeeking && !view.isPlaying) {
- runCatching { view.start() }
- }
- },
- modifier = Modifier
- .fillMaxWidth()
- .weight(1f)
- .padding(horizontal = 10.dp),
- )
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 14.dp, vertical = 10.dp),
- verticalArrangement = Arrangement.spacedBy(8.dp),
- ) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- IconButton(
- onClick = {
- val view = videoViewRef ?: return@IconButton
- if (videoPlaying) {
- runCatching { view.pause() }
- videoPlaying = false
- } else {
- runCatching { view.start() }
- videoPlaying = true
- }
- },
- enabled = videoPrepared,
- ) {
- Icon(
- imageVector = if (videoPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
- contentDescription = if (videoPlaying) "Pause video" else "Play video",
- tint = Color.White,
- )
- }
- Text(
- text = extractFileName(videoUrl),
- modifier = Modifier.weight(1f),
- style = MaterialTheme.typography.bodyMedium,
- color = Color.White,
- maxLines = 1,
- )
- }
-
- val videoProgress = if (videoDurationMs <= 0) 0f else {
- (videoPositionMs.toFloat() / videoDurationMs.toFloat()).coerceIn(0f, 1f)
- }
- Slider(
- value = if (isVideoSeeking) videoSeekFraction else videoProgress,
- onValueChange = { fraction ->
- isVideoSeeking = true
- videoSeekFraction = fraction.coerceIn(0f, 1f)
- },
- onValueChangeFinished = {
- val targetMs = (videoDurationMs * videoSeekFraction).roundToInt()
- runCatching { videoViewRef?.seekTo(targetMs) }
- videoPositionMs = targetMs
- isVideoSeeking = false
- },
- enabled = videoPrepared && videoDurationMs > 0,
+ Text(
+ text = "${pagerState.currentPage + 1}/${items.size.coerceAtLeast(1)}",
+ style = MaterialTheme.typography.titleSmall,
+ color = Color.White,
)
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Text(
- text = formatDuration(videoPositionMs),
- style = MaterialTheme.typography.labelSmall,
- color = Color.White.copy(alpha = 0.85f),
+ Spacer(modifier = Modifier.size(48.dp))
+ }
+
+ HorizontalPager(
+ state = pagerState,
+ modifier = Modifier.weight(1f),
+ ) { page ->
+ val item = items[page]
+ when (item.type) {
+ ChatViewerMediaType.Image -> ZoomableImageViewerPage(
+ imageUrl = item.url,
+ onDismiss = onDismiss,
)
- Text(
- text = if (videoDurationMs > 0) formatDuration(videoDurationMs) else "--:--",
- style = MaterialTheme.typography.labelSmall,
- color = Color.White.copy(alpha = 0.85f),
+ ChatViewerMediaType.Video -> VideoViewerPage(
+ videoUrl = item.url,
+ isCurrentPage = pagerState.currentPage == page,
+ onDismiss = onDismiss,
)
}
}
}
}
+
+ LaunchedEffect(pagerState.currentPage) {
+ onPageChanged(pagerState.currentPage)
+ }
+}
+
+@Composable
+private fun ZoomableImageViewerPage(
+ imageUrl: String,
+ onDismiss: () -> Unit,
+) {
+ var scale by remember(imageUrl) { mutableStateOf(1f) }
+ var offset by remember(imageUrl) { mutableStateOf(Offset.Zero) }
+ var dismissOffsetY by remember(imageUrl) { mutableStateOf(0f) }
+ val canDismiss = scale <= 1.05f
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .pointerInput(imageUrl) {
+ detectTransformGestures { _, pan, zoom, _ ->
+ val nextScale = (scale * zoom).coerceIn(1f, 4f)
+ scale = nextScale
+ offset = if (nextScale <= 1.02f) {
+ Offset.Zero
+ } else {
+ offset + pan
+ }
+ }
+ }
+ .pointerInput(imageUrl, canDismiss) {
+ detectVerticalDragGestures(
+ onVerticalDrag = { change, dragAmount ->
+ if (!canDismiss) return@detectVerticalDragGestures
+ change.consume()
+ dismissOffsetY += dragAmount
+ },
+ onDragEnd = {
+ if (kotlin.math.abs(dismissOffsetY) > 180f) {
+ onDismiss()
+ } else {
+ dismissOffsetY = 0f
+ }
+ },
+ onDragCancel = {
+ dismissOffsetY = 0f
+ },
+ )
+ },
+ contentAlignment = Alignment.Center,
+ ) {
+ AsyncImage(
+ model = imageUrl,
+ contentDescription = "Attachment",
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ .graphicsLayer {
+ scaleX = scale
+ scaleY = scale
+ translationX = offset.x
+ translationY = offset.y + dismissOffsetY
+ },
+ contentScale = ContentScale.Fit,
+ )
+ }
+}
+
+@Composable
+private fun VideoViewerPage(
+ videoUrl: String,
+ isCurrentPage: Boolean,
+ onDismiss: () -> Unit,
+) {
+ val playerState = rememberManagedMediaPlayerState(
+ url = videoUrl,
+ autoPlay = isCurrentPage,
+ speedOptions = listOf(1f),
+ )
+ var dismissOffsetY by remember(videoUrl) { mutableStateOf(0f) }
+
+ LaunchedEffect(isCurrentPage) {
+ if (!isCurrentPage && playerState.isPlaying) {
+ playerState.pause()
+ }
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .pointerInput(videoUrl) {
+ detectVerticalDragGestures(
+ onVerticalDrag = { change, dragAmount ->
+ change.consume()
+ dismissOffsetY += dragAmount
+ },
+ onDragEnd = {
+ if (kotlin.math.abs(dismissOffsetY) > 180f) {
+ onDismiss()
+ } else {
+ dismissOffsetY = 0f
+ }
+ },
+ onDragCancel = {
+ dismissOffsetY = 0f
+ },
+ )
+ }
+ .graphicsLayer {
+ translationY = dismissOffsetY
+ },
+ verticalArrangement = Arrangement.SpaceBetween,
+ ) {
+ AndroidView(
+ factory = { androidContext ->
+ PlayerView(androidContext).apply {
+ useController = true
+ resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
+ controllerAutoShow = true
+ controllerHideOnTouch = false
+ player = playerState.player
+ }
+ },
+ update = { view ->
+ view.player = playerState.player
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f)
+ .padding(horizontal = 10.dp),
+ )
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 14.dp, vertical = 10.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ IconButton(
+ onClick = { playerState.togglePlayback(resetIfEnded = true) },
+ enabled = playerState.isPrepared,
+ ) {
+ Icon(
+ imageVector = if (playerState.isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
+ contentDescription = if (playerState.isPlaying) "Pause video" else "Play video",
+ tint = Color.White,
+ )
+ }
+ Text(
+ text = extractFileName(videoUrl),
+ modifier = Modifier.weight(1f),
+ style = MaterialTheme.typography.bodyMedium,
+ color = Color.White,
+ maxLines = 1,
+ )
+ }
+
+ val videoProgress = if (playerState.durationMs <= 0L) 0f else {
+ (playerState.positionMs.toFloat() / playerState.durationMs.toFloat()).coerceIn(0f, 1f)
+ }
+ Slider(
+ value = videoProgress,
+ onValueChange = { fraction -> playerState.seekToFraction(fraction) },
+ enabled = playerState.isPrepared,
+ )
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(
+ text = formatDuration((playerState.positionMs / 1000L).toInt()),
+ style = MaterialTheme.typography.labelSmall,
+ color = Color.White,
+ )
+ Text(
+ text = formatDuration((playerState.durationMs / 1000L).toInt()),
+ style = MaterialTheme.typography.labelSmall,
+ color = Color.White,
+ )
+ }
+ }
+ }
+}
+
+private class ManagedMediaPlayerState(
+ val sourceId: String,
+ val player: ExoPlayer,
+ private val speedOptions: List,
+) {
+ var isPrepared by mutableStateOf(false)
+ var isPlaying by mutableStateOf(false)
+ var durationMs by mutableStateOf(0L)
+ var positionMs by mutableStateOf(0L)
+ var speedIndex by mutableStateOf(0)
+
+ fun speedLabel(): String = formatAudioSpeed(speedOptions.getOrElse(speedIndex) { 1f })
+
+ fun pause() {
+ runCatching { player.pause() }
+ isPlaying = false
+ AppAudioFocusCoordinator.release(sourceId)
+ }
+
+ fun stop(resetPosition: Boolean = true) {
+ pause()
+ if (resetPosition) {
+ seekTo(0L)
+ }
+ }
+
+ fun seekTo(targetPositionMs: Long) {
+ val safeTarget = targetPositionMs.coerceAtLeast(0L)
+ runCatching { player.seekTo(safeTarget) }
+ positionMs = safeTarget
+ }
+
+ fun seekToFraction(fraction: Float) {
+ if (durationMs <= 0L) return
+ seekTo((durationMs * fraction.coerceIn(0f, 1f)).toLong())
+ }
+
+ fun togglePlayback(resetIfEnded: Boolean = true) {
+ if (!isPrepared) return
+ if (isPlaying) {
+ pause()
+ return
+ }
+ if (resetIfEnded && durationMs > 0L && positionMs >= durationMs - 200L) {
+ seekTo(0L)
+ }
+ runCatching { player.setPlaybackParameters(PlaybackParameters(speedOptions.getOrElse(speedIndex) { 1f })) }
+ AppAudioFocusCoordinator.request(sourceId)
+ runCatching { player.play() }
+ isPlaying = true
+ }
+
+ fun cycleSpeed() {
+ if (speedOptions.size <= 1) return
+ speedIndex = (speedIndex + 1) % speedOptions.size
+ runCatching { player.setPlaybackParameters(PlaybackParameters(speedOptions[speedIndex])) }
+ }
+}
+
+@Composable
+@UnstableApi
+private fun rememberManagedMediaPlayerState(
+ url: String,
+ autoPlay: Boolean = false,
+ speedOptions: List = listOf(1f, 1.5f, 2f),
+): ManagedMediaPlayerState {
+ val context = LocalContext.current
+ val sourceId = remember(url) { "player:$url" }
+ val player = remember(url) {
+ ExoPlayer.Builder(context).build().apply {
+ setMediaItem(MediaItem.fromUri(url))
+ if (autoPlay) {
+ playWhenReady = true
+ }
+ prepare()
+ }
+ }
+ val state = remember(url) {
+ ManagedMediaPlayerState(
+ sourceId = sourceId,
+ player = player,
+ speedOptions = speedOptions,
+ )
+ }
+
+ DisposableEffect(player) {
+ val listener = object : Player.Listener {
+ override fun onPlaybackStateChanged(playbackState: Int) {
+ state.isPrepared = playbackState == Player.STATE_READY || playbackState == Player.STATE_BUFFERING
+ if (playbackState == Player.STATE_READY) {
+ state.durationMs = player.duration.coerceAtLeast(0L)
+ }
+ if (playbackState == Player.STATE_ENDED) {
+ state.isPlaying = false
+ state.positionMs = player.duration.coerceAtLeast(0L)
+ AppAudioFocusCoordinator.release(sourceId)
+ }
+ }
+
+ override fun onIsPlayingChanged(isPlaying: Boolean) {
+ state.isPlaying = isPlaying
+ }
+ }
+ player.addListener(listener)
+ onDispose {
+ player.removeListener(listener)
+ runCatching { player.stop() }
+ AppAudioFocusCoordinator.release(sourceId)
+ player.release()
+ }
+ }
+
+ LaunchedEffect(sourceId) {
+ AppAudioFocusCoordinator.activeSourceId.collectLatest { activeId ->
+ if (activeId != null && activeId != sourceId && state.isPlaying) {
+ state.pause()
+ }
+ }
+ }
+
+ LaunchedEffect(state.isPlaying, state.isPrepared) {
+ if (!state.isPlaying || !state.isPrepared) return@LaunchedEffect
+ while (state.isPlaying) {
+ state.positionMs = runCatching { player.currentPosition.coerceAtLeast(0L) }.getOrDefault(state.positionMs)
+ state.durationMs = runCatching { player.duration.coerceAtLeast(0L) }.getOrDefault(state.durationMs)
+ delay(250)
+ }
+ }
+
+ LaunchedEffect(autoPlay, sourceId) {
+ if (autoPlay) {
+ AppAudioFocusCoordinator.request(sourceId)
+ }
+ }
+
+ return state
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -2462,14 +2612,25 @@ private data class PickedMediaPayload(
val bytes: ByteArray,
)
+private enum class CameraCaptureMode {
+ Photo,
+ Video,
+}
+
private enum class CircleFinalizeAction {
Send,
Cancel,
}
+private enum class CameraFinalizeAction {
+ Send,
+ Cancel,
+}
+
@Composable
-private fun CircleVideoRecorderDialog(
+private fun CameraCaptureDialog(
lifecycleOwner: androidx.lifecycle.LifecycleOwner,
+ mode: CameraCaptureMode,
onDismiss: () -> Unit,
onSend: (PickedMediaPayload) -> Unit,
) {
@@ -2481,13 +2642,15 @@ private fun CircleVideoRecorderDialog(
scaleType = PreviewView.ScaleType.FILL_CENTER
}
}
- var videoCapture by remember { mutableStateOf?>(null) }
- var activeRecording by remember { mutableStateOf(null) }
- var activeFile by remember { mutableStateOf(null) }
- var isRecording by remember { mutableStateOf(false) }
- var isLocked by remember { mutableStateOf(false) }
- var durationMs by remember { mutableStateOf(0L) }
- var finalizeAction by remember { mutableStateOf(null) }
+ var lensFacing by remember { mutableStateOf(CameraSelector.LENS_FACING_BACK) }
+ var imageCapture by remember(mode) { mutableStateOf(null) }
+ var videoCapture by remember(mode) { mutableStateOf?>(null) }
+ var activeRecording by remember(mode) { mutableStateOf(null) }
+ var pendingFile by remember(mode) { mutableStateOf(null) }
+ var isCapturing by remember(mode) { mutableStateOf(false) }
+ var isRecording by remember(mode) { mutableStateOf(false) }
+ var durationMs by remember(mode) { mutableStateOf(0L) }
+ var finalizeAction by remember(mode) { mutableStateOf(null) }
LaunchedEffect(isRecording) {
while (isRecording) {
@@ -2496,7 +2659,274 @@ private fun CircleVideoRecorderDialog(
}
}
- DisposableEffect(lifecycleOwner, previewView) {
+ DisposableEffect(lifecycleOwner, previewView, lensFacing, mode) {
+ val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
+ val listener = Runnable {
+ val provider = runCatching { cameraProviderFuture.get() }.getOrNull() ?: return@Runnable
+ val selector = CameraSelector.Builder()
+ .requireLensFacing(lensFacing)
+ .build()
+ val preview = Preview.Builder().build().apply {
+ setSurfaceProvider(previewView.surfaceProvider)
+ }
+ val captureImage = ImageCapture.Builder().build()
+ val captureVideo = VideoCapture.withOutput(
+ Recorder.Builder()
+ .setQualitySelector(
+ QualitySelector.fromOrderedList(
+ listOf(Quality.FHD, Quality.HD, Quality.SD),
+ FallbackStrategy.lowerQualityOrHigherThan(Quality.SD),
+ ),
+ )
+ .build(),
+ )
+ runCatching {
+ provider.unbindAll()
+ provider.bindToLifecycle(
+ lifecycleOwner,
+ selector,
+ preview,
+ captureImage,
+ captureVideo,
+ )
+ imageCapture = captureImage
+ videoCapture = captureVideo
+ }
+ }
+ cameraProviderFuture.addListener(listener, mainExecutor)
+ onDispose {
+ runCatching { activeRecording?.stop() }
+ activeRecording = null
+ isRecording = false
+ isCapturing = false
+ finalizeAction = null
+ val provider = runCatching { cameraProviderFuture.get() }.getOrNull()
+ runCatching { provider?.unbindAll() }
+ runCatching { pendingFile?.delete() }
+ pendingFile = null
+ }
+ }
+
+ fun finishWithFile(file: File, mimeType: String, fileNamePrefix: String) {
+ val bytes = runCatching { file.readBytes() }.getOrNull()
+ val extension = file.extension.takeIf { it.isNotBlank() }?.let { ".$it" }.orEmpty()
+ runCatching { file.delete() }
+ pendingFile = null
+ isCapturing = false
+ isRecording = false
+ durationMs = 0L
+ if (bytes == null || bytes.isEmpty()) return
+ onSend(
+ PickedMediaPayload(
+ fileName = "${fileNamePrefix}_${System.currentTimeMillis()}$extension",
+ mimeType = mimeType,
+ bytes = bytes,
+ ),
+ )
+ }
+
+ fun capturePhoto() {
+ val capture = imageCapture ?: return
+ if (isCapturing || isRecording) return
+ val file = createTempCaptureFile(context = context, prefix = "camera_photo_", suffix = ".jpg")
+ pendingFile = file
+ isCapturing = true
+ val output = ImageCapture.OutputFileOptions.Builder(file).build()
+ capture.takePicture(
+ output,
+ mainExecutor,
+ object : ImageCapture.OnImageSavedCallback {
+ override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
+ finishWithFile(file = file, mimeType = "image/jpeg", fileNamePrefix = "camera_photo")
+ onDismiss()
+ }
+
+ override fun onError(exception: ImageCaptureException) {
+ runCatching { file.delete() }
+ pendingFile = null
+ isCapturing = false
+ }
+ },
+ )
+ }
+
+ fun toggleVideoRecording() {
+ if (isCapturing) return
+ val existingRecording = activeRecording
+ if (existingRecording != null) {
+ finalizeAction = CameraFinalizeAction.Send
+ existingRecording.stop()
+ activeRecording = null
+ isRecording = false
+ return
+ }
+ val capture = videoCapture ?: return
+ val file = createTempCaptureFile(context = context, prefix = "camera_video_", suffix = ".mp4")
+ pendingFile = file
+ isCapturing = true
+ durationMs = 0L
+ val output = FileOutputOptions.Builder(file).build()
+ var pending = capture.output.prepareRecording(context, output)
+ if (
+ ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
+ PackageManager.PERMISSION_GRANTED
+ ) {
+ pending = pending.withAudioEnabled()
+ }
+ activeRecording = pending.start(mainExecutor) { event ->
+ when (event) {
+ is VideoRecordEvent.Start -> {
+ isRecording = true
+ isCapturing = false
+ }
+ is VideoRecordEvent.Finalize -> {
+ val completedFile = pendingFile
+ val action = finalizeAction
+ finalizeAction = null
+ activeRecording = null
+ isRecording = false
+ isCapturing = false
+ if (event.hasError() || completedFile == null || !completedFile.exists()) {
+ runCatching { completedFile?.delete() }
+ pendingFile = null
+ return@start
+ }
+ when (action) {
+ CameraFinalizeAction.Send -> {
+ finishWithFile(
+ file = completedFile,
+ mimeType = "video/mp4",
+ fileNamePrefix = "camera_video",
+ )
+ onDismiss()
+ }
+ CameraFinalizeAction.Cancel, null -> {
+ runCatching { completedFile.delete() }
+ pendingFile = null
+ durationMs = 0L
+ }
+ }
+ }
+ else -> Unit
+ }
+ }
+ }
+
+ Dialog(
+ onDismissRequest = {
+ if (activeRecording != null) {
+ finalizeAction = CameraFinalizeAction.Cancel
+ activeRecording?.stop()
+ } else {
+ onDismiss()
+ }
+ },
+ ) {
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ shape = RoundedCornerShape(24.dp),
+ color = MaterialTheme.colorScheme.surface,
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(14.dp),
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .aspectRatio(3f / 4f)
+ .clip(RoundedCornerShape(24.dp))
+ .background(Color.Black),
+ ) {
+ AndroidView(
+ factory = { previewView },
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+ Text(
+ text = if (mode == CameraCaptureMode.Photo) {
+ "Take photo"
+ } else {
+ if (isRecording) "Recording ${formatDuration(durationMs.toInt())}" else "Record video"
+ },
+ style = MaterialTheme.typography.titleMedium,
+ )
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ IconButton(
+ onClick = { lensFacing = if (lensFacing == CameraSelector.LENS_FACING_BACK) CameraSelector.LENS_FACING_FRONT else CameraSelector.LENS_FACING_BACK },
+ ) {
+ Icon(imageVector = Icons.AutoMirrored.Filled.Reply, contentDescription = "Switch camera")
+ }
+ Surface(
+ shape = CircleShape,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier
+ .size(68.dp)
+ .clip(CircleShape)
+ .clickable(enabled = !isCapturing) {
+ if (mode == CameraCaptureMode.Photo) {
+ capturePhoto()
+ } else {
+ toggleVideoRecording()
+ }
+ },
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ Icon(
+ imageVector = if (mode == CameraCaptureMode.Photo) Icons.Filled.AddAPhoto else if (isRecording) Icons.Filled.Pause else Icons.Filled.Movie,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onPrimary,
+ )
+ }
+ }
+ IconButton(onClick = onDismiss) {
+ Icon(imageVector = Icons.Filled.Close, contentDescription = "Close camera")
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun CircleVideoRecorderDialog(
+ lifecycleOwner: androidx.lifecycle.LifecycleOwner,
+ onDismiss: () -> Unit,
+ onSend: (PickedMediaPayload) -> Unit,
+) {
+ val context = LocalContext.current
+ val mainExecutor = remember(context) { ContextCompat.getMainExecutor(context) }
+ val scope = rememberCoroutineScope()
+ val previewView = remember {
+ PreviewView(context).apply {
+ implementationMode = PreviewView.ImplementationMode.COMPATIBLE
+ scaleType = PreviewView.ScaleType.FILL_CENTER
+ }
+ }
+ var lensFacing by remember { mutableStateOf(CameraSelector.LENS_FACING_FRONT) }
+ var videoCapture by remember { mutableStateOf?>(null) }
+ var activeRecording by remember { mutableStateOf(null) }
+ var activeFile by remember { mutableStateOf(null) }
+ var isRecording by remember { mutableStateOf(false) }
+ var isLocked by remember { mutableStateOf(false) }
+ var isProcessing by remember { mutableStateOf(false) }
+ var durationMs by remember { mutableStateOf(0L) }
+ var finalizeAction by remember { mutableStateOf(null) }
+ var autoStartPending by remember { mutableStateOf(true) }
+ val recordingProgress = (durationMs / 60_000f).coerceIn(0f, 1f)
+ val progressTrackColor = Color.White.copy(alpha = 0.22f)
+ val progressActiveColor = Color.White
+
+ DisposableEffect(lifecycleOwner, previewView, lensFacing) {
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
val listener = Runnable {
val provider = runCatching { cameraProviderFuture.get() }.getOrNull() ?: return@Runnable
@@ -2504,14 +2934,21 @@ private fun CircleVideoRecorderDialog(
setSurfaceProvider(previewView.surfaceProvider)
}
val recorder = Recorder.Builder()
- .setQualitySelector(QualitySelector.from(Quality.HD))
+ .setQualitySelector(
+ QualitySelector.fromOrderedList(
+ listOf(Quality.FHD, Quality.HD, Quality.SD),
+ FallbackStrategy.lowerQualityOrHigherThan(Quality.SD),
+ ),
+ )
.build()
val capture = VideoCapture.withOutput(recorder)
runCatching {
provider.unbindAll()
provider.bindToLifecycle(
lifecycleOwner,
- CameraSelector.DEFAULT_FRONT_CAMERA,
+ CameraSelector.Builder()
+ .requireLensFacing(lensFacing)
+ .build(),
preview,
capture,
)
@@ -2527,6 +2964,7 @@ private fun CircleVideoRecorderDialog(
runCatching { provider?.unbindAll() }
runCatching { activeFile?.delete() }
activeFile = null
+ videoCapture = null
}
}
@@ -2538,6 +2976,17 @@ private fun CircleVideoRecorderDialog(
isRecording = false
}
+ LaunchedEffect(isRecording) {
+ while (isRecording) {
+ delay(100L)
+ durationMs += 100L
+ if (durationMs >= 60_000L) {
+ stopWith(CircleFinalizeAction.Send)
+ break
+ }
+ }
+ }
+
fun startRecording() {
if (isRecording) return
val capture = videoCapture ?: return
@@ -2572,21 +3021,33 @@ private fun CircleVideoRecorderDialog(
}
when (action) {
CircleFinalizeAction.Send -> {
- val bytes = runCatching { completedFile.readBytes() }.getOrNull()
- if (bytes != null && bytes.isNotEmpty()) {
- onSend(
- PickedMediaPayload(
- fileName = "circle_${System.currentTimeMillis()}.mp4",
- mimeType = "video/mp4",
- bytes = bytes,
- ),
- )
+ isProcessing = true
+ scope.launch {
+ val squaredFile = runCatching {
+ transcodeCircleVideoToSquare(context = context, inputFile = completedFile)
+ }.getOrNull()
+ val sourceFile = squaredFile ?: completedFile
+ val bytes = runCatching { sourceFile.readBytes() }.getOrNull()
+ if (bytes != null && bytes.isNotEmpty()) {
+ onSend(
+ PickedMediaPayload(
+ fileName = "circle_${System.currentTimeMillis()}.mp4",
+ mimeType = "video/mp4",
+ bytes = bytes,
+ ),
+ )
+ }
+ runCatching { completedFile.delete() }
+ if (squaredFile != null && squaredFile != completedFile) {
+ runCatching { squaredFile.delete() }
+ }
+ isProcessing = false
+ onDismiss()
}
- runCatching { completedFile.delete() }
- onDismiss()
}
CircleFinalizeAction.Cancel, null -> {
runCatching { completedFile.delete() }
+ onDismiss()
}
}
}
@@ -2595,128 +3056,222 @@ private fun CircleVideoRecorderDialog(
}
}
- Dialog(onDismissRequest = {
- if (!isRecording) {
- onDismiss()
- } else {
- stopWith(CircleFinalizeAction.Cancel)
+ LaunchedEffect(videoCapture, autoStartPending) {
+ if (autoStartPending && videoCapture != null && activeRecording == null) {
+ autoStartPending = false
+ startRecording()
}
- }) {
- Surface(
+ }
+
+ Surface(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.Black.copy(alpha = 0.78f)),
+ color = Color.Black.copy(alpha = 0.65f),
+ ) {
+ Box(
modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
- shape = RoundedCornerShape(24.dp),
- color = MaterialTheme.colorScheme.surface,
+ .fillMaxSize()
+ .windowInsetsPadding(WindowInsets.safeDrawing),
) {
Column(
modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
+ .fillMaxSize()
+ .padding(horizontal = 20.dp, vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(12.dp),
+ verticalArrangement = Arrangement.SpaceBetween,
) {
+ Spacer(modifier = Modifier.height(1.dp))
Box(
- modifier = Modifier
- .size(260.dp)
- .clip(CircleShape)
- .background(Color.Black),
+ modifier = Modifier.weight(1f),
+ contentAlignment = Alignment.Center,
) {
- AndroidView(
- factory = { previewView },
- modifier = Modifier.fillMaxSize(),
- )
- }
- Text(
- text = if (isRecording) "Recording ${formatDuration(durationMs.toInt())}" else "Circle video",
- style = MaterialTheme.typography.titleMedium,
- )
- if (!isRecording) {
- CircleHoldToRecordButton(
- onStart = { startRecording() },
- onLock = { isLocked = true },
- onCancel = { stopWith(CircleFinalizeAction.Cancel) },
- onRelease = { stopWith(CircleFinalizeAction.Send) },
- )
- TextButton(onClick = onDismiss) { Text(stringResource(id = R.string.common_cancel)) }
- } else {
- if (isLocked) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally),
- ) {
- TextButton(onClick = { stopWith(CircleFinalizeAction.Cancel) }) {
- Text(stringResource(id = R.string.common_cancel))
- }
- Button(onClick = { stopWith(CircleFinalizeAction.Send) }) {
- Text(stringResource(id = R.string.common_send))
- }
- }
- } else {
- CircleHoldToRecordButton(
- onStart = {},
- onLock = { isLocked = true },
- onCancel = { stopWith(CircleFinalizeAction.Cancel) },
- onRelease = { stopWith(CircleFinalizeAction.Send) },
+ Box(
+ modifier = Modifier
+ .size(310.dp)
+ .clip(CircleShape)
+ .background(Color.Black),
+ ) {
+ AndroidView(
+ factory = { previewView },
+ modifier = Modifier.fillMaxSize(),
)
- }
- }
- }
- }
- }
-}
-
-@Composable
-private fun CircleHoldToRecordButton(
- onStart: () -> Unit,
- onLock: () -> Unit,
- onCancel: () -> Unit,
- onRelease: () -> Unit,
-) {
- Box(
- modifier = Modifier
- .pointerInput(Unit) {
- awaitEachGesture {
- val down = awaitFirstDown(requireUnconsumed = false)
- onStart()
- 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
- onLock()
- break
- }
- if (!locked && delta.x < -150f) {
- cancelled = true
- onCancel()
- break
- }
- if (!change.pressed) {
- if (!cancelled && !locked) onRelease()
- break
- }
- if (change.positionChange() != Offset.Zero) {
- change.consume()
+ Canvas(modifier = Modifier.fillMaxSize()) {
+ val strokeWidth = 6.dp.toPx()
+ val inset = strokeWidth / 2f
+ drawArc(
+ color = progressTrackColor,
+ startAngle = -90f,
+ sweepAngle = 360f,
+ useCenter = false,
+ topLeft = Offset(inset, inset),
+ size = androidx.compose.ui.geometry.Size(
+ width = size.width - strokeWidth,
+ height = size.height - strokeWidth,
+ ),
+ style = Stroke(width = strokeWidth, cap = StrokeCap.Round),
+ )
+ if (recordingProgress > 0f) {
+ drawArc(
+ color = progressActiveColor,
+ startAngle = -90f,
+ sweepAngle = 360f * recordingProgress,
+ useCenter = false,
+ topLeft = Offset(inset, inset),
+ size = androidx.compose.ui.geometry.Size(
+ width = size.width - strokeWidth,
+ height = size.height - strokeWidth,
+ ),
+ style = Stroke(width = strokeWidth, cap = StrokeCap.Round),
+ )
+ }
+ }
+ }
+ }
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 4.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ IconButton(
+ onClick = {
+ if (isRecording) {
+ stopWith(CircleFinalizeAction.Cancel)
+ } else {
+ onDismiss()
+ }
+ },
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Close,
+ contentDescription = "Cancel circle recording",
+ tint = Color.White,
+ )
+ }
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Box(
+ modifier = Modifier
+ .size(8.dp)
+ .clip(CircleShape)
+ .background(if (isRecording) Color.Red else Color.White.copy(alpha = 0.45f)),
+ )
+ Text(
+ text = if (isProcessing) "Processing…" else formatDuration(durationMs.toInt()),
+ color = Color.White,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ )
+ }
+ IconButton(
+ onClick = {
+ if (!isRecording) {
+ lensFacing = if (lensFacing == CameraSelector.LENS_FACING_BACK) {
+ CameraSelector.LENS_FACING_FRONT
+ } else {
+ CameraSelector.LENS_FACING_BACK
+ }
+ autoStartPending = true
+ }
+ },
+ enabled = !isRecording,
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.Reply,
+ contentDescription = "Switch camera",
+ tint = if (!isRecording) Color.White else Color.White.copy(alpha = 0.4f),
+ )
+ }
+ }
+ Surface(
+ shape = RoundedCornerShape(999.dp),
+ color = Color.Black.copy(alpha = 0.42f),
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = if (isLocked) "Locked" else "← Отмена",
+ color = Color.White.copy(alpha = 0.8f),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ Text(
+ text = if (isLocked) "Tap to send" else "↑ Lock",
+ color = Color.White.copy(alpha = 0.72f),
+ style = MaterialTheme.typography.bodySmall,
+ )
+ Surface(
+ shape = CircleShape,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier
+ .size(68.dp)
+ .clip(CircleShape)
+ .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,
+ )
+ }
+ }
}
}
}
- },
- ) {
- Surface(
- shape = CircleShape,
- color = MaterialTheme.colorScheme.primary.copy(alpha = 0.18f),
- ) {
- Row(
- modifier = Modifier.padding(horizontal = 20.dp, vertical = 10.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- ) {
- Icon(imageVector = Icons.Filled.RadioButtonChecked, contentDescription = null)
- Text("Slide up to lock, left to cancel")
}
}
}
@@ -2830,6 +3385,13 @@ private fun MessageBubble(
val hasSingleStickerAttachment = singleImageAttachment?.let {
isStickerAsset(fileUrl = it.fileUrl, fileType = it.fileType)
} == true || hasLegacyStickerImage
+ val hasSingleCircleAttachment = message.attachments.size == 1 &&
+ message.attachments.first().fileType.lowercase().startsWith("video/") &&
+ (
+ message.type.contains("circle_video", ignoreCase = true) ||
+ message.type.contains("video_note", ignoreCase = true)
+ )
+ val renderWithoutBubble = hasSingleStickerAttachment || hasSingleCircleAttachment
Row(
modifier = Modifier.fillMaxWidth(),
@@ -2846,7 +3408,7 @@ private fun MessageBubble(
Column(
modifier = Modifier
.fillMaxWidth(
- if (hasSingleStickerAttachment) {
+ if (hasSingleStickerAttachment || hasSingleCircleAttachment) {
if (renderAsChannelPost) 0.58f else 0.52f
} else if (renderAsChannelPost) {
0.94f
@@ -2854,9 +3416,9 @@ private fun MessageBubble(
0.8f
},
)
- .widthIn(min = if (hasSingleStickerAttachment) 96.dp else if (renderAsChannelPost) 120.dp else 82.dp)
+ .widthIn(min = if (renderWithoutBubble) 96.dp else if (renderAsChannelPost) 120.dp else 82.dp)
.then(
- if (hasSingleStickerAttachment) {
+ if (renderWithoutBubble) {
Modifier
} else {
Modifier.background(
@@ -2874,8 +3436,8 @@ private fun MessageBubble(
onLongClick = onLongPress,
)
.padding(
- horizontal = if (hasSingleStickerAttachment) 0.dp else if (renderAsChannelPost) 11.dp else 10.dp,
- vertical = if (hasSingleStickerAttachment) 2.dp else 7.dp,
+ horizontal = if (renderWithoutBubble) 0.dp else if (renderAsChannelPost) 11.dp else 10.dp,
+ vertical = if (renderWithoutBubble) 2.dp else 7.dp,
),
verticalArrangement = Arrangement.spacedBy(3.dp),
) {
@@ -3025,11 +3587,33 @@ private fun MessageBubble(
}
if (isLegacyVideoUrlMessage) {
- VideoAttachmentCard(
- url = textUrl,
- fileType = message.type,
- onOpenViewer = { onAttachmentVideoClick(textUrl) },
- )
+ if (
+ message.type.contains("video_note", ignoreCase = true) ||
+ message.type.contains("circle_video", ignoreCase = true)
+ ) {
+ CircleVideoAttachmentPlayer(
+ url = textUrl,
+ playbackTitle = message.senderDisplayName?.ifBlank { null } ?: extractFileName(textUrl),
+ playbackSubtitle = stringResource(
+ id = R.string.chat_playback_subtitle_circle,
+ formatMessageTime(message.createdAt),
+ ),
+ messageId = message.id,
+ onPlaybackChanged = onAudioPlaybackChanged,
+ forceStopAudioSourceId = forceStopAudioSourceId,
+ forceToggleAudioSourceId = forceToggleAudioSourceId,
+ forceCycleSpeedAudioSourceId = forceCycleSpeedAudioSourceId,
+ onForceStopAudioSourceHandled = onForceStopAudioSourceHandled,
+ onForceToggleAudioSourceHandled = onForceToggleAudioSourceHandled,
+ onForceCycleSpeedAudioSourceHandled = onForceCycleSpeedAudioSourceHandled,
+ )
+ } else {
+ VideoAttachmentCard(
+ url = textUrl,
+ fileType = message.type,
+ onOpenViewer = { onAttachmentVideoClick(textUrl) },
+ )
+ }
}
if (reactions.isNotEmpty()) {
@@ -3150,7 +3734,22 @@ private fun MessageBubble(
message.type.contains("video_note", ignoreCase = true) ||
message.type.contains("circle_video", ignoreCase = true)
) {
- CircleVideoAttachmentPlayer(url = attachment.fileUrl)
+ CircleVideoAttachmentPlayer(
+ url = attachment.fileUrl,
+ playbackTitle = message.senderDisplayName?.ifBlank { null } ?: extractFileName(attachment.fileUrl),
+ playbackSubtitle = stringResource(
+ id = R.string.chat_playback_subtitle_circle,
+ formatMessageTime(message.createdAt),
+ ),
+ messageId = message.id,
+ onPlaybackChanged = onAudioPlaybackChanged,
+ forceStopAudioSourceId = forceStopAudioSourceId,
+ forceToggleAudioSourceId = forceToggleAudioSourceId,
+ forceCycleSpeedAudioSourceId = forceCycleSpeedAudioSourceId,
+ onForceStopAudioSourceHandled = onForceStopAudioSourceHandled,
+ onForceToggleAudioSourceHandled = onForceToggleAudioSourceHandled,
+ onForceCycleSpeedAudioSourceHandled = onForceCycleSpeedAudioSourceHandled,
+ )
} else {
VideoAttachmentCard(
url = attachment.fileUrl,
@@ -3299,49 +3898,144 @@ private fun VideoAttachmentCard(
}
@Composable
-private fun CircleVideoAttachmentPlayer(url: String) {
- val context = LocalContext.current
- val player = remember(url) {
- ExoPlayer.Builder(context).build().apply {
- setMediaItem(MediaItem.fromUri(url))
- repeatMode = Player.REPEAT_MODE_ONE
- volume = 0f
- playWhenReady = true
- prepare()
- }
+@UnstableApi
+private fun CircleVideoAttachmentPlayer(
+ url: String,
+ playbackTitle: String,
+ playbackSubtitle: String,
+ messageId: Long,
+ onPlaybackChanged: (TopAudioStrip) -> Unit,
+ forceStopAudioSourceId: String?,
+ forceToggleAudioSourceId: String?,
+ forceCycleSpeedAudioSourceId: String?,
+ onForceStopAudioSourceHandled: (String) -> Unit,
+ onForceToggleAudioSourceHandled: (String) -> Unit,
+ onForceCycleSpeedAudioSourceHandled: (String) -> Unit,
+) {
+ val playerState = rememberManagedMediaPlayerState(
+ url = url,
+ speedOptions = listOf(1f),
+ )
+ fun emitTopStrip(playing: Boolean) {
+ onPlaybackChanged(
+ TopAudioStrip(
+ messageId = messageId,
+ sourceId = playerState.sourceId,
+ title = playbackTitle,
+ subtitle = playbackSubtitle,
+ isPlaying = playing,
+ speedLabel = "1x",
+ ),
+ )
}
- DisposableEffect(player) {
- onDispose {
- player.release()
- }
+ LaunchedEffect(forceStopAudioSourceId) {
+ if (forceStopAudioSourceId != playerState.sourceId) return@LaunchedEffect
+ playerState.stop(resetPosition = true)
+ emitTopStrip(false)
+ onForceStopAudioSourceHandled(playerState.sourceId)
}
- Column(
+ LaunchedEffect(forceToggleAudioSourceId) {
+ if (forceToggleAudioSourceId != playerState.sourceId) return@LaunchedEffect
+ playerState.togglePlayback(resetIfEnded = true)
+ emitTopStrip(playerState.isPlaying)
+ onForceToggleAudioSourceHandled(playerState.sourceId)
+ }
+ LaunchedEffect(forceCycleSpeedAudioSourceId) {
+ if (forceCycleSpeedAudioSourceId != playerState.sourceId) return@LaunchedEffect
+ onForceCycleSpeedAudioSourceHandled(playerState.sourceId)
+ }
+ LaunchedEffect(playerState.isPlaying) {
+ emitTopStrip(playerState.isPlaying)
+ }
+ val progress = if (playerState.durationMs > 0L) {
+ (playerState.positionMs.toFloat() / playerState.durationMs.toFloat()).coerceIn(0f, 1f)
+ } else {
+ 0f
+ }
+ val progressTrackColor = Color.White.copy(alpha = 0.22f)
+ val progressActiveColor = MaterialTheme.colorScheme.primary
+ Box(
modifier = Modifier
.fillMaxWidth()
- .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.45f), RoundedCornerShape(10.dp))
- .padding(8.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(6.dp),
+ .aspectRatio(1f)
+ .padding(horizontal = 6.dp, vertical = 2.dp),
+ contentAlignment = Alignment.Center,
) {
- AndroidView(
- factory = { context ->
- PlayerView(context).apply {
- useController = false
- resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM
- this.player = player
- }
- },
+ Box(
modifier = Modifier
- .size(220.dp)
+ .fillMaxSize()
.clip(CircleShape),
- update = { view ->
- view.player = player
- },
- )
- Text(stringResource(id = R.string.chat_circle_video), style = MaterialTheme.typography.labelSmall)
+ ) {
+ AndroidView(
+ factory = { context ->
+ PlayerView(context).apply {
+ useController = false
+ resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM
+ player = playerState.player
+ }
+ },
+ modifier = Modifier.fillMaxSize(),
+ update = { view ->
+ view.player = playerState.player
+ },
+ )
+ Canvas(modifier = Modifier.fillMaxSize()) {
+ val strokeWidth = 6.dp.toPx()
+ val inset = strokeWidth / 2f
+ drawArc(
+ color = progressTrackColor,
+ startAngle = -90f,
+ sweepAngle = 360f,
+ useCenter = false,
+ topLeft = Offset(inset, inset),
+ size = androidx.compose.ui.geometry.Size(
+ width = size.width - strokeWidth,
+ height = size.height - strokeWidth,
+ ),
+ style = Stroke(width = strokeWidth, cap = StrokeCap.Round),
+ )
+ if (progress > 0f) {
+ drawArc(
+ color = progressActiveColor,
+ startAngle = -90f,
+ sweepAngle = 360f * progress,
+ useCenter = false,
+ topLeft = Offset(inset, inset),
+ size = androidx.compose.ui.geometry.Size(
+ width = size.width - strokeWidth,
+ height = size.height - strokeWidth,
+ ),
+ style = Stroke(width = strokeWidth, cap = StrokeCap.Round),
+ )
+ }
+ }
+ Surface(
+ shape = CircleShape,
+ color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.42f),
+ modifier = Modifier
+ .align(Alignment.Center)
+ .size(52.dp)
+ .clip(CircleShape)
+ .clickable(enabled = playerState.isPrepared) {
+ if (!playerState.isPrepared) return@clickable
+ playerState.togglePlayback(resetIfEnded = true)
+ emitTopStrip(playerState.isPlaying)
+ },
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ Icon(
+ imageVector = if (playerState.isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
+ contentDescription = if (playerState.isPlaying) "Pause circle video" else "Play circle video",
+ tint = Color.White,
+ modifier = Modifier.size(24.dp),
+ )
+ }
+ }
+ }
}
}
+
@Composable
private fun FileAttachmentRow(
fileUrl: String,
@@ -3717,133 +4411,58 @@ private fun AudioAttachmentPlayer(
onForceToggleAudioSourceHandled: (String) -> Unit,
onForceCycleSpeedAudioSourceHandled: (String) -> Unit,
) {
- var isPlaying by remember(url) { mutableStateOf(false) }
- var isPrepared by remember(url) { mutableStateOf(false) }
- var durationMs by remember(url) { mutableStateOf(0) }
- var positionMs by remember(url) { mutableStateOf(0) }
- val speedOptions = listOf(1f, 1.5f, 2f)
- var speedIndex by remember(url) { mutableStateOf(0) }
+ val playerState = rememberManagedMediaPlayerState(
+ url = url,
+ speedOptions = listOf(1f, 1.5f, 2f),
+ )
var isSeeking by remember(url) { mutableStateOf(false) }
var seekFraction by remember(url) { mutableStateOf(0f) }
+
fun emitTopStrip(playing: Boolean) {
onPlaybackChanged(
TopAudioStrip(
messageId = messageId,
- sourceId = "player:$url",
+ sourceId = playerState.sourceId,
title = playbackTitle,
subtitle = playbackSubtitle,
isPlaying = playing,
- speedLabel = formatAudioSpeed(speedOptions[speedIndex]),
+ speedLabel = playerState.speedLabel(),
)
)
}
- val mediaPlayer = remember(url) {
- MediaPlayer().apply {
- setAudioAttributes(
- AudioAttributes.Builder()
- .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
- .setUsage(AudioAttributes.USAGE_MEDIA)
- .build()
- )
- setOnPreparedListener { player ->
- isPrepared = true
- durationMs = player.duration.coerceAtLeast(0)
- }
- setOnCompletionListener { player ->
- isPlaying = false
- runCatching { player.seekTo(0) }
- positionMs = 0
- AppAudioFocusCoordinator.release("player:$url")
- emitTopStrip(false)
- }
- setDataSource(url)
- prepareAsync()
- }
- }
- LaunchedEffect(url) {
- AppAudioFocusCoordinator.activeSourceId.collectLatest { activeId ->
- val currentId = "player:$url"
- if (activeId != null && activeId != currentId && isPlaying) {
- runCatching { mediaPlayer.pause() }
- isPlaying = false
- emitTopStrip(false)
- }
- }
- }
+
LaunchedEffect(forceStopAudioSourceId) {
- val currentId = "player:$url"
+ val currentId = playerState.sourceId
if (forceStopAudioSourceId != currentId) return@LaunchedEffect
- runCatching { mediaPlayer.pause() }
- runCatching { mediaPlayer.seekTo(0) }
- positionMs = 0
- isPlaying = false
- AppAudioFocusCoordinator.release(currentId)
+ playerState.stop(resetPosition = true)
emitTopStrip(false)
onForceStopAudioSourceHandled(currentId)
}
LaunchedEffect(forceToggleAudioSourceId) {
- val currentId = "player:$url"
+ val currentId = playerState.sourceId
if (forceToggleAudioSourceId != currentId) return@LaunchedEffect
- if (!isPrepared) {
- onForceToggleAudioSourceHandled(currentId)
- return@LaunchedEffect
- }
- if (isPlaying) {
- runCatching { mediaPlayer.pause() }
- isPlaying = false
- AppAudioFocusCoordinator.release(currentId)
- emitTopStrip(false)
- } else {
- if (durationMs > 0 && positionMs >= durationMs - 200) {
- runCatching { mediaPlayer.seekTo(0) }
- positionMs = 0
- }
- runCatching {
- mediaPlayer.playbackParams = mediaPlayer.playbackParams.setSpeed(speedOptions[speedIndex])
- }
- AppAudioFocusCoordinator.request(currentId)
- runCatching { mediaPlayer.start() }
- isPlaying = true
- emitTopStrip(true)
- }
+ playerState.togglePlayback(resetIfEnded = true)
+ emitTopStrip(playerState.isPlaying)
onForceToggleAudioSourceHandled(currentId)
}
LaunchedEffect(forceCycleSpeedAudioSourceId) {
- val currentId = "player:$url"
+ val currentId = playerState.sourceId
if (forceCycleSpeedAudioSourceId != currentId) return@LaunchedEffect
- speedIndex = (speedIndex + 1) % speedOptions.size
- if (isPrepared) {
- runCatching {
- mediaPlayer.playbackParams = mediaPlayer.playbackParams.setSpeed(speedOptions[speedIndex])
- }
- }
- emitTopStrip(isPlaying)
+ playerState.cycleSpeed()
+ emitTopStrip(playerState.isPlaying)
onForceCycleSpeedAudioSourceHandled(currentId)
}
- LaunchedEffect(isPlaying, isPrepared, isSeeking) {
- if (!isPlaying || !isPrepared || isSeeking) return@LaunchedEffect
- while (isPlaying) {
- positionMs = runCatching { mediaPlayer.currentPosition }.getOrDefault(positionMs)
- delay(250)
- }
+ LaunchedEffect(playerState.isPlaying) {
+ emitTopStrip(playerState.isPlaying)
}
- LaunchedEffect(url, isPrepared) {
- if (durationMs > 0) return@LaunchedEffect
+ LaunchedEffect(url, playerState.isPrepared) {
+ if (playerState.durationMs > 0L) return@LaunchedEffect
val fallbackDuration = resolveRemoteAudioDurationMs(url)
if (fallbackDuration != null && fallbackDuration > 0) {
- durationMs = fallbackDuration
- }
- }
- DisposableEffect(mediaPlayer) {
- onDispose {
- runCatching {
- mediaPlayer.stop()
- }
- AppAudioFocusCoordinator.release("player:$url")
- emitTopStrip(false)
- mediaPlayer.release()
+ playerState.durationMs = fallbackDuration.toLong()
}
}
+
Column(
modifier = Modifier
.fillMaxWidth()
@@ -3856,48 +4475,34 @@ private fun AudioAttachmentPlayer(
) {
Surface(
shape = CircleShape,
- color = MaterialTheme.colorScheme.primary.copy(alpha = if (isPrepared) 1f else 0.45f),
+ color = MaterialTheme.colorScheme.primary.copy(alpha = if (playerState.isPrepared) 1f else 0.45f),
modifier = Modifier
.size(34.dp)
.clip(CircleShape)
- .clickable(enabled = isPrepared) {
- if (!isPrepared) return@clickable
- if (isPlaying) {
- mediaPlayer.pause()
- isPlaying = false
- AppAudioFocusCoordinator.release("player:$url")
- emitTopStrip(false)
- } else {
- if (durationMs > 0 && positionMs >= durationMs - 200) {
- runCatching { mediaPlayer.seekTo(0) }
- positionMs = 0
- }
- runCatching {
- mediaPlayer.playbackParams = mediaPlayer.playbackParams.setSpeed(speedOptions[speedIndex])
- }
- AppAudioFocusCoordinator.request("player:$url")
- mediaPlayer.start()
- isPlaying = true
- emitTopStrip(true)
- }
+ .clickable(enabled = playerState.isPrepared) {
+ if (!playerState.isPrepared) return@clickable
+ playerState.togglePlayback(resetIfEnded = true)
+ emitTopStrip(playerState.isPlaying)
},
) {
Box(contentAlignment = Alignment.Center) {
Icon(
- imageVector = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
- contentDescription = if (isPlaying) "Pause" else "Play",
+ imageVector = if (playerState.isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
+ contentDescription = if (playerState.isPlaying) "Pause" else "Play",
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(18.dp),
)
}
}
+ val durationMs = playerState.durationMs.toInt()
+ val positionMs = playerState.positionMs.toInt()
val progress = if (durationMs <= 0) 0f else (positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f)
WaveformSeekBar(
waveform = waveform,
progress = if (isSeeking) seekFraction else progress,
- isPlaying = isPlaying,
+ isPlaying = playerState.isPlaying,
seed = url,
- enabled = isPrepared && durationMs > 0,
+ enabled = playerState.isPrepared && durationMs > 0,
modifier = Modifier
.weight(1f)
.height(26.dp),
@@ -3908,8 +4513,7 @@ private fun AudioAttachmentPlayer(
onSeekFinished = {
if (durationMs > 0) {
val targetMs = (durationMs * seekFraction).roundToInt()
- runCatching { mediaPlayer.seekTo(targetMs) }
- positionMs = targetMs
+ playerState.seekTo(targetMs.toLong())
}
isSeeking = false
},
@@ -3930,18 +4534,13 @@ private fun AudioAttachmentPlayer(
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.65f),
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
- .clickable(enabled = isPrepared) {
- speedIndex = (speedIndex + 1) % speedOptions.size
- if (isPrepared) {
- runCatching {
- mediaPlayer.playbackParams = mediaPlayer.playbackParams.setSpeed(speedOptions[speedIndex])
- }
- }
- emitTopStrip(isPlaying)
+ .clickable(enabled = playerState.isPrepared) {
+ playerState.cycleSpeed()
+ emitTopStrip(playerState.isPlaying)
},
) {
Text(
- text = formatAudioSpeed(speedOptions[speedIndex]),
+ text = playerState.speedLabel(),
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -3951,6 +4550,7 @@ private fun AudioAttachmentPlayer(
}
}
+
@Composable
private fun WaveformSeekBar(
waveform: List?,
@@ -3977,7 +4577,7 @@ private fun WaveformSeekBar(
)
var widthPx by remember { mutableStateOf(0f) }
val normalizedProgress = progress.coerceIn(0f, 1f)
- BoxWithConstraints(
+ Box(
modifier = modifier
.onSizeChanged { widthPx = it.width.toFloat() }
.clip(RoundedCornerShape(8.dp))
@@ -4050,55 +4650,96 @@ private fun buildFallbackWaveform(seed: String, bars: Int = 56): List {
}
@Composable
-private fun VoiceHoldToRecordButton(
+private fun UnifiedRecordButton(
enabled: Boolean,
isLocked: Boolean,
- onStart: () -> Unit,
+ useCircleRecording: Boolean,
+ onToggleMode: () -> Unit,
+ onStartVoice: () -> Unit,
+ onStartCircle: () -> Unit,
onLock: () -> Unit,
onCancel: () -> Unit,
onRelease: () -> Unit,
) {
- Box(
+ val viewConfiguration = LocalViewConfiguration.current
+ Surface(
+ shape = RoundedCornerShape(999.dp),
+ color = if (enabled) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.surfaceVariant
+ },
modifier = Modifier
- .pointerInput(enabled, isLocked) {
+ .semantics {
+ contentDescription = if (useCircleRecording) {
+ "Tap to switch to voice, hold to record circle"
+ } else {
+ "Tap to switch to circle, hold to record voice"
+ }
+ }
+ .pointerInput(enabled, isLocked, useCircleRecording, viewConfiguration.longPressTimeoutMillis) {
if (!enabled || isLocked) return@pointerInput
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
- onStart()
- var cancelledBySlide = false
- var lockedBySlide = false
- while (true) {
- val event = awaitPointerEvent()
- val change = event.changes.first()
- val delta = change.position - down.position
- if (!lockedBySlide && delta.y < -120f) {
- lockedBySlide = true
- onLock()
- }
- if (!lockedBySlide && delta.x < -150f) {
- cancelledBySlide = true
- onCancel()
- break
- }
- if (!change.pressed) {
- if (!cancelledBySlide && !lockedBySlide) {
- onRelease()
+ val up = withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis.toLong()) {
+ waitForUpOrCancellation()
+ }
+ if (up != null) {
+ onToggleMode()
+ return@awaitEachGesture
+ }
+ if (useCircleRecording) {
+ onStartCircle()
+ while (true) {
+ val event = awaitPointerEvent()
+ val change = event.changes.first()
+ if (!change.pressed) {
+ break
+ }
+ if (change.positionChange() != Offset.Zero) {
+ change.consume()
}
- break
}
- if (change.positionChange() != androidx.compose.ui.geometry.Offset.Zero) {
- change.consume()
+ } else {
+ onStartVoice()
+ var cancelledBySlide = false
+ var lockedBySlide = false
+ while (true) {
+ val event = awaitPointerEvent()
+ val change = event.changes.first()
+ val delta = change.position - down.position
+ if (!lockedBySlide && delta.y < -120f) {
+ lockedBySlide = true
+ onLock()
+ }
+ if (!lockedBySlide && delta.x < -150f) {
+ cancelledBySlide = true
+ onCancel()
+ break
+ }
+ if (!change.pressed) {
+ if (!cancelledBySlide && !lockedBySlide) {
+ onRelease()
+ }
+ break
+ }
+ if (change.positionChange() != Offset.Zero) {
+ change.consume()
+ }
}
}
}
},
) {
- Button(
- onClick = {},
- enabled = enabled,
- modifier = Modifier.semantics { contentDescription = "Hold to record voice message" },
+ Box(
+ modifier = Modifier.padding(horizontal = 22.dp, vertical = 14.dp),
+ contentAlignment = Alignment.Center,
) {
- Icon(imageVector = Icons.Filled.Mic, contentDescription = null)
+ Icon(
+ imageVector = if (useCircleRecording) Icons.Filled.RadioButtonChecked else Icons.Filled.Mic,
+ contentDescription = null,
+ tint = if (enabled) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
+ )
}
}
}
@@ -4132,6 +4773,54 @@ private fun resolveRemoteAudioDurationMs(url: String): Int? {
}.getOrNull()
}
+@UnstableApi
+private suspend fun transcodeCircleVideoToSquare(
+ context: Context,
+ inputFile: File,
+): File = suspendCancellableCoroutine { continuation ->
+ val outputFile = createTempCaptureFile(context = context, prefix = "circle_square_", suffix = ".mp4")
+ val transformer = Transformer.Builder(context).build()
+ val editedMediaItem = EditedMediaItem.Builder(MediaItem.fromUri(Uri.fromFile(inputFile)))
+ .setEffects(
+ Effects(
+ emptyList(),
+ listOf(
+ Presentation.createForAspectRatio(
+ 1f,
+ Presentation.LAYOUT_SCALE_TO_FIT_WITH_CROP,
+ ),
+ ),
+ ),
+ )
+ .build()
+
+ val listener = object : Transformer.Listener {
+ override fun onCompleted(composition: Composition, exportResult: ExportResult) {
+ if (continuation.isActive) {
+ continuation.resume(outputFile)
+ }
+ }
+
+ override fun onError(
+ composition: Composition,
+ exportResult: ExportResult,
+ exportException: ExportException,
+ ) {
+ runCatching { outputFile.delete() }
+ if (continuation.isActive) {
+ continuation.resumeWithException(exportException)
+ }
+ }
+ }
+ transformer.addListener(listener)
+ continuation.invokeOnCancellation {
+ transformer.cancel()
+ transformer.removeListener(listener)
+ runCatching { outputFile.delete() }
+ }
+ transformer.start(editedMediaItem, outputFile.absolutePath)
+}
+
private fun extractFileName(url: String): String {
return runCatching {
val path = java.net.URI(url).path.orEmpty()
@@ -4334,40 +5023,93 @@ private fun ChatInfoTabContent(
.padding(horizontal = 10.dp, vertical = 9.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(10.dp),
- ) {
- Box(
+ if (entry.previewIsVideo && !entry.resourceUrl.isNullOrBlank()) {
+ Row(
modifier = Modifier
- .size(46.dp)
- .clip(RoundedCornerShape(8.dp))
- .background(MaterialTheme.colorScheme.primaryContainer),
- contentAlignment = Alignment.Center,
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(14.dp))
+ .clickable { onEntryClick(entry) }
+ .padding(2.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
- Icon(
- imageVector = Icons.Filled.Mic,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.onPrimaryContainer,
- )
+ Box(
+ modifier = Modifier
+ .size(72.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.16f)),
+ contentAlignment = Alignment.Center,
+ ) {
+ AsyncImage(
+ model = ImageRequest.Builder(LocalContext.current)
+ .data(entry.resourceUrl)
+ .videoFrameMillis(0)
+ .crossfade(true)
+ .build(),
+ contentDescription = null,
+ modifier = Modifier
+ .matchParentSize()
+ .clip(CircleShape),
+ contentScale = ContentScale.Crop,
+ )
+ Icon(
+ imageVector = Icons.Filled.PlayArrow,
+ contentDescription = null,
+ tint = Color.White,
+ modifier = Modifier.size(26.dp),
+ )
+ }
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = entry.title,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ )
+ Text(
+ text = entry.subtitle,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 2,
+ )
+ }
}
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = entry.title,
- style = MaterialTheme.typography.bodyMedium,
- fontWeight = FontWeight.SemiBold,
- maxLines = 1,
- )
- Text(
- text = entry.subtitle,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- maxLines = 2,
- )
+ } else {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Box(
+ modifier = Modifier
+ .size(46.dp)
+ .clip(RoundedCornerShape(8.dp))
+ .background(MaterialTheme.colorScheme.primaryContainer),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Mic,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onPrimaryContainer,
+ )
+ }
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = entry.title,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ )
+ Text(
+ text = entry.subtitle,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 2,
+ )
+ }
}
}
- if (!entry.resourceUrl.isNullOrBlank()) {
+ if (!entry.resourceUrl.isNullOrBlank() && !entry.previewIsVideo) {
AudioAttachmentPlayer(
url = entry.resourceUrl,
waveform = null,
@@ -4708,6 +5450,19 @@ private fun buildChatInfoEntries(
val skipFromInfo = normalized.contains("gif") || normalized.contains("webp")
if (skipFromInfo) return@forEach
when {
+ message.type.contains("circle_video", ignoreCase = true) &&
+ normalized.startsWith("video/") -> {
+ entries += ChatInfoEntry(
+ type = ChatInfoEntryType.Voice,
+ title = context.getString(R.string.chat_audio_strip_video_note),
+ subtitle = "$time • ${formatBytes(attachment.fileSize)}",
+ resourceUrl = attachment.fileUrl,
+ sourceMessageId = message.id,
+ previewImageUrl = attachment.fileUrl,
+ previewIsVideo = true,
+ )
+ }
+
normalized.startsWith("image/") || normalized.startsWith("video/") -> {
entries += ChatInfoEntry(
type = ChatInfoEntryType.Media,
@@ -4755,6 +5510,56 @@ private fun buildChatInfoEntries(
return entries
}
+private fun buildChatViewerMediaItems(messages: List): List {
+ return messages
+ .flatMap { message ->
+ val messageType = message.type.lowercase(Locale.getDefault())
+ val items = message.attachments.mapNotNull { attachment ->
+ val fileType = attachment.fileType.lowercase(Locale.getDefault())
+ when {
+ fileType.startsWith("image/") &&
+ !fileType.contains("gif") &&
+ !fileType.contains("webp") -> ChatViewerMediaItem(
+ url = attachment.fileUrl,
+ type = ChatViewerMediaType.Image,
+ )
+
+ fileType.startsWith("video/") &&
+ !messageType.contains("circle_video") &&
+ !messageType.contains("video_note") -> ChatViewerMediaItem(
+ url = attachment.fileUrl,
+ type = ChatViewerMediaType.Video,
+ )
+
+ else -> null
+ }
+ }.toMutableList()
+
+ val legacyUrl = message.text?.trim()
+ if (
+ items.isEmpty() &&
+ !legacyUrl.isNullOrBlank() &&
+ legacyUrl.startsWith("http", ignoreCase = true)
+ ) {
+ when {
+ messageType == "image" &&
+ !isGifLikeUrl(legacyUrl) &&
+ !isStickerLikeUrl(legacyUrl) -> items += ChatViewerMediaItem(
+ url = legacyUrl,
+ type = ChatViewerMediaType.Image,
+ )
+
+ messageType == "video" -> items += ChatViewerMediaItem(
+ url = legacyUrl,
+ type = ChatViewerMediaType.Video,
+ )
+ }
+ }
+ items
+ }
+ .distinctBy { "${it.type}:${it.url}" }
+}
+
private fun isGifLikeUrl(url: String): Boolean {
if (url.isBlank()) return false
val normalized = url.lowercase(Locale.getDefault())
diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/voice/VoiceRecorder.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/voice/VoiceRecorder.kt
index b717822..3f6f8cf 100644
--- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/voice/VoiceRecorder.kt
+++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/voice/VoiceRecorder.kt
@@ -1,6 +1,7 @@
package ru.daemonlord.messenger.ui.chat.voice
import android.content.Context
+import android.os.Build
import android.media.MediaRecorder
import java.io.File
import java.util.UUID
@@ -13,7 +14,7 @@ class VoiceRecorder(private val context: Context) {
fun start(): Boolean {
return runCatching {
val file = File(context.cacheDir, "voice_${UUID.randomUUID()}.m4a")
- val mediaRecorder = MediaRecorder().apply {
+ val mediaRecorder = createMediaRecorder().apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
@@ -34,6 +35,15 @@ class VoiceRecorder(private val context: Context) {
}
}
+ private fun createMediaRecorder(): MediaRecorder {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ MediaRecorder(context)
+ } else {
+ @Suppress("DEPRECATION")
+ MediaRecorder()
+ }
+ }
+
fun elapsedMillis(nowMillis: Long = System.currentTimeMillis()): Long {
if (startedAtMillis <= 0L) return 0L
return (nowMillis - startedAtMillis).coerceAtLeast(0L)
diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt
index 96cc244..69e0e4d 100644
--- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt
+++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt
@@ -103,6 +103,7 @@ fun ChatListRoute(
onInviteTokenConsumed: () -> Unit,
isMainBarVisible: Boolean,
onMainBarVisibilityChanged: (Boolean) -> Unit,
+ selectedChatId: Long? = null,
viewModel: ChatListViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
@@ -153,6 +154,7 @@ fun ChatListRoute(
onBanMember = viewModel::banMember,
onUnbanMember = viewModel::unbanMember,
onToggleDayNightMode = viewModel::toggleDayNightMode,
+ selectedChatId = selectedChatId,
)
}
@@ -194,6 +196,7 @@ fun ChatListScreen(
onBanMember: (Long, Long) -> Unit,
onUnbanMember: (Long, Long) -> Unit,
onToggleDayNightMode: ((AppThemeMode) -> Unit) -> Unit,
+ selectedChatId: Long? = null,
) {
val context = LocalContext.current
var managementExpanded by remember { mutableStateOf(false) }
@@ -534,6 +537,7 @@ fun ChatListScreen(
chat = chat,
isSelecting = selectedChatIds.isNotEmpty(),
isSelected = selectedChatIds.contains(chat.id),
+ isActive = selectedChatIds.isEmpty() && selectedChatId == chat.id,
onClick = {
if (selectedChatIds.isNotEmpty()) {
selectedChatIds = if (selectedChatIds.contains(chat.id)) {
@@ -1392,12 +1396,20 @@ private fun ChatRow(
chat: ChatItem,
isSelecting: Boolean,
isSelected: Boolean,
+ isActive: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
+ .clip(RoundedCornerShape(18.dp))
+ .background(
+ when {
+ isActive -> MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
+ else -> Color.Transparent
+ },
+ )
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/navigation/AppNavGraph.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/navigation/AppNavGraph.kt
index e53bb9f..74b552b 100644
--- a/android/app/src/main/java/ru/daemonlord/messenger/ui/navigation/AppNavGraph.kt
+++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/navigation/AppNavGraph.kt
@@ -3,17 +3,21 @@ package ru.daemonlord.messenger.ui.navigation
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
+import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -28,6 +32,7 @@ import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -37,10 +42,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.core.content.ContextCompat
import androidx.compose.ui.Alignment
@@ -99,16 +107,23 @@ fun MessengerNavHost(
) {
val uiState by viewModel.uiState.collectAsState()
val context = LocalContext.current
+ val configuration = LocalConfiguration.current
+ val isTabletLandscape = remember(configuration.screenWidthDp, configuration.screenHeightDp) {
+ configuration.screenWidthDp >= 840 && configuration.screenWidthDp > configuration.screenHeightDp
+ }
val notificationPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) {}
var isMainBarVisible by remember { mutableStateOf(true) }
+ var tabletSelectedChatId by rememberSaveable { mutableStateOf(null) }
val backStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = backStackEntry?.destination?.route
val mainTabRoutes = remember {
setOf(Routes.Chats, Routes.Contacts, Routes.Settings, Routes.Profile)
}
- val showMainBar = currentRoute in mainTabRoutes
+ val isChatRoute = currentRoute?.startsWith("${Routes.Chat}/") == true
+ val showMainBar = (currentRoute in mainTabRoutes) &&
+ !(isTabletLandscape && (currentRoute == Routes.Chats || isChatRoute))
LaunchedEffect(Unit) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return@LaunchedEffect
@@ -143,11 +158,21 @@ fun MessengerNavHost(
}
if (uiState.isAuthenticated) {
if (notificationChatId != null) {
- navController.navigate("${Routes.Chat}/$notificationChatId") {
- popUpTo(navController.graph.findStartDestination().id) {
- inclusive = true
+ if (isTabletLandscape) {
+ tabletSelectedChatId = notificationChatId
+ navController.navigate(Routes.Chats) {
+ popUpTo(navController.graph.findStartDestination().id) {
+ inclusive = true
+ }
+ launchSingleTop = true
+ }
+ } else {
+ navController.navigate("${Routes.Chat}/$notificationChatId") {
+ popUpTo(navController.graph.findStartDestination().id) {
+ inclusive = true
+ }
+ launchSingleTop = true
}
- launchSingleTop = true
}
onNotificationConsumed()
} else {
@@ -277,15 +302,25 @@ fun MessengerNavHost(
}
composable(route = Routes.Chats) {
- ChatListRoute(
- inviteToken = inviteToken,
- onInviteTokenConsumed = onInviteTokenConsumed,
- onOpenChat = { chatId ->
- navController.navigate("${Routes.Chat}/$chatId")
- },
- isMainBarVisible = isMainBarVisible,
- onMainBarVisibilityChanged = { isMainBarVisible = it },
- )
+ if (isTabletLandscape) {
+ TabletChatsRoute(
+ parentNavController = navController,
+ selectedChatId = tabletSelectedChatId,
+ onSelectChat = { tabletSelectedChatId = it },
+ inviteToken = inviteToken,
+ onInviteTokenConsumed = onInviteTokenConsumed,
+ )
+ } else {
+ ChatListRoute(
+ inviteToken = inviteToken,
+ onInviteTokenConsumed = onInviteTokenConsumed,
+ onOpenChat = { chatId ->
+ navController.navigate("${Routes.Chat}/$chatId")
+ },
+ isMainBarVisible = isMainBarVisible,
+ onMainBarVisibilityChanged = { isMainBarVisible = it },
+ )
+ }
}
composable(route = Routes.Contacts) {
@@ -323,9 +358,30 @@ fun MessengerNavHost(
navArgument("chatId") { type = NavType.LongType }
),
) { backStackEntry ->
- ChatRoute(
- onBack = { navController.popBackStack() },
- )
+ val selectedChatId = backStackEntry.arguments?.getLong("chatId")
+ if (isTabletLandscape) {
+ LaunchedEffect(selectedChatId) {
+ tabletSelectedChatId = selectedChatId
+ navController.navigate(Routes.Chats) {
+ popUpTo(navController.graph.findStartDestination().id) {
+ saveState = true
+ }
+ launchSingleTop = true
+ restoreState = true
+ }
+ }
+ TabletChatsRoute(
+ parentNavController = navController,
+ selectedChatId = tabletSelectedChatId ?: selectedChatId,
+ onSelectChat = { tabletSelectedChatId = it },
+ inviteToken = inviteToken,
+ onInviteTokenConsumed = onInviteTokenConsumed,
+ )
+ } else {
+ ChatRoute(
+ onBack = { navController.popBackStack() },
+ )
+ }
}
}
@@ -355,14 +411,116 @@ fun MessengerNavHost(
}
}
+@Composable
+private fun TabletChatsRoute(
+ parentNavController: NavHostController,
+ selectedChatId: Long?,
+ onSelectChat: (Long?) -> Unit,
+ inviteToken: String?,
+ onInviteTokenConsumed: () -> Unit,
+) {
+ val configuration = LocalConfiguration.current
+ val listPaneWidth = remember(configuration.screenWidthDp) {
+ (configuration.screenWidthDp * 0.29f).dp.coerceIn(320.dp, 420.dp)
+ }
+
+ BackHandler(enabled = selectedChatId != null) {
+ onSelectChat(null)
+ }
+
+ Row(
+ modifier = Modifier
+ .fillMaxSize()
+ .windowInsetsPadding(WindowInsets.safeDrawing),
+ ) {
+ Surface(
+ modifier = Modifier
+ .width(listPaneWidth)
+ .fillMaxHeight(),
+ color = MaterialTheme.colorScheme.surface,
+ tonalElevation = 2.dp,
+ ) {
+ Box(modifier = Modifier.fillMaxSize()) {
+ ChatListRoute(
+ inviteToken = inviteToken,
+ onInviteTokenConsumed = onInviteTokenConsumed,
+ onOpenChat = { chatId ->
+ onSelectChat(chatId)
+ },
+ isMainBarVisible = true,
+ onMainBarVisibilityChanged = {},
+ selectedChatId = selectedChatId,
+ )
+ MainBottomBar(
+ currentRoute = Routes.Chats,
+ onNavigate = { route ->
+ if (route == Routes.Chats) {
+ parentNavController.navigate(Routes.Chats) {
+ popUpTo(parentNavController.graph.findStartDestination().id) {
+ saveState = true
+ }
+ launchSingleTop = true
+ restoreState = true
+ }
+ } else {
+ parentNavController.navigate(route) {
+ popUpTo(parentNavController.graph.findStartDestination().id) {
+ saveState = true
+ }
+ launchSingleTop = true
+ restoreState = true
+ }
+ }
+ },
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .navigationBarsPadding()
+ .padding(horizontal = 12.dp, vertical = 12.dp),
+ )
+ }
+ }
+
+ Surface(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxHeight(),
+ color = MaterialTheme.colorScheme.background,
+ tonalElevation = 0.dp,
+ ) {
+ key(selectedChatId) {
+ val detailNavController = rememberNavController()
+ NavHost(
+ navController = detailNavController,
+ startDestination = if (selectedChatId == null) "empty" else "${Routes.Chat}/$selectedChatId",
+ ) {
+ composable("empty") {
+ TabletChatPlaceholder()
+ }
+ composable(
+ route = "${Routes.Chat}/{chatId}",
+ arguments = listOf(navArgument("chatId") { type = NavType.LongType }),
+ ) {
+ ChatRoute(
+ onBack = {
+ onSelectChat(null)
+ },
+ showBackButton = false,
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
@Composable
private fun MainBottomBar(
currentRoute: String?,
onNavigate: (String) -> Unit,
+ modifier: Modifier = Modifier.padding(horizontal = 12.dp),
) {
Surface(
- modifier = Modifier
- .padding(horizontal = 12.dp)
+ modifier = modifier
.fillMaxWidth(),
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.88f),
@@ -454,3 +612,58 @@ private fun SessionCheckingScreen() {
CircularProgressIndicator()
}
}
+
+@Composable
+private fun TabletChatPlaceholder() {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(32.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Surface(
+ shape = RoundedCornerShape(32.dp),
+ color = MaterialTheme.colorScheme.surface.copy(alpha = 0.82f),
+ tonalElevation = 2.dp,
+ shadowElevation = 10.dp,
+ modifier = Modifier.fillMaxWidth(0.56f),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 32.dp, vertical = 40.dp),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Surface(
+ shape = RoundedCornerShape(24.dp),
+ color = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f),
+ modifier = Modifier.padding(bottom = 18.dp),
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.Chat,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(18.dp),
+ )
+ }
+ Text(
+ text = stringResource(id = R.string.tablet_chat_placeholder_title),
+ style = MaterialTheme.typography.headlineSmall,
+ )
+ Text(
+ text = stringResource(id = R.string.tablet_chat_placeholder_body),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 12.dp),
+ )
+ Text(
+ text = stringResource(id = R.string.tablet_chat_placeholder_hint),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.78f),
+ modifier = Modifier.padding(top = 16.dp),
+ )
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml
index 458502b..4db39ae 100644
--- a/android/app/src/main/res/values-ru/strings.xml
+++ b/android/app/src/main/res/values-ru/strings.xml
@@ -28,6 +28,9 @@
Удалить выбранные чаты
Вы уверены, что хотите удалить выбранные чаты?
Удалить для всех (где доступно)
+ Выберите чат
+ Откройте диалог из левой колонки, и он появится здесь.
+ Открытый чат остаётся справа, а список слева всегда под рукой.
Все
Люди
@@ -158,6 +161,7 @@
Стикеры
Голосовое сообщение • %1$s
Аудио • %1$s
+ Видеокружок • %1$s
Пользователь #%1$d
%1$s • id %2$d
id %1$d
@@ -332,4 +336,5 @@
Коды восстановления перегенерированы.
Неверные учетные данные.
Не авторизовано.
+ Видеокружок
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index a0798f7..cf2e1f8 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -68,6 +68,9 @@
Delete selected chats
Are you sure you want to delete selected chats?
Delete for all (where allowed)
+ Choose a chat
+ Select a conversation from the left column to open it here.
+ Your current chat stays open here while the list remains visible.
Cancel
Confirm
@@ -158,6 +161,7 @@
Stickers
Voice message • %1$s
Audio • %1$s
+ Video note • %1$s
User #%1$d
%1$s • id %2$d
id %1$d
@@ -332,4 +336,5 @@
Recovery codes regenerated.
Invalid credentials.
Unauthorized.
+ Video note
diff --git a/app/notifications/tasks.py b/app/notifications/tasks.py
index 62347b1..b130055 100644
--- a/app/notifications/tasks.py
+++ b/app/notifications/tasks.py
@@ -15,6 +15,19 @@ logger = logging.getLogger(__name__)
_firebase_app: firebase_admin.App | None = None
+_worker_loop: asyncio.AbstractEventLoop | None = None
+
+
+def _get_worker_loop() -> asyncio.AbstractEventLoop:
+ global _worker_loop
+ if _worker_loop is None or _worker_loop.is_closed():
+ _worker_loop = asyncio.new_event_loop()
+ return _worker_loop
+
+
+def _run_async(coro):
+ loop = _get_worker_loop()
+ return loop.run_until_complete(coro)
def _get_firebase_app() -> firebase_admin.App | None:
@@ -63,7 +76,7 @@ def _send_fcm_to_user(user_id: int, title: str, body: str, data: dict[str, Any])
logger.info("Skipping FCM send for user=%s: Firebase disabled", user_id)
return
- tokens = asyncio.run(_load_tokens(user_id))
+ tokens = _run_async(_load_tokens(user_id))
if not tokens:
return
@@ -83,9 +96,9 @@ def _send_fcm_to_user(user_id: int, title: str, body: str, data: dict[str, Any])
try:
messaging.send(message, app=app)
except messaging.UnregisteredError:
- asyncio.run(_delete_invalid_token(user_id=user_id, platform=platform, token=token))
+ _run_async(_delete_invalid_token(user_id=user_id, platform=platform, token=token))
except messaging.SenderIdMismatchError:
- asyncio.run(_delete_invalid_token(user_id=user_id, platform=platform, token=token))
+ _run_async(_delete_invalid_token(user_id=user_id, platform=platform, token=token))
except Exception:
logger.exception("FCM send failed for user=%s platform=%s", user_id, platform)
diff --git a/web/src/components/ChatInfoPanel.tsx b/web/src/components/ChatInfoPanel.tsx
index a59d853..f557c04 100644
--- a/web/src/components/ChatInfoPanel.tsx
+++ b/web/src/components/ChatInfoPanel.tsx
@@ -104,9 +104,21 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
const count = chat.members_count ?? members.length;
return count <= 1;
}, [chat, isGroupLike, myRoleNormalized, members.length]);
- const photoAttachments = useMemo(() => attachments.filter((item) => item.file_type.startsWith("image/")).sort((a, b) => b.id - a.id), [attachments]);
- const videoAttachments = useMemo(() => attachments.filter((item) => item.file_type.startsWith("video/")).sort((a, b) => b.id - a.id), [attachments]);
- const voiceAttachments = useMemo(() => attachments.filter((item) => item.message_type === "voice").sort((a, b) => b.id - a.id), [attachments]);
+ const photoAttachments = useMemo(
+ () => attachments.filter((item) => item.file_type.startsWith("image/")).sort((a, b) => b.id - a.id),
+ [attachments]
+ );
+ const videoAttachments = useMemo(
+ () =>
+ attachments
+ .filter((item) => item.file_type.startsWith("video/") && item.message_type !== "circle_video")
+ .sort((a, b) => b.id - a.id),
+ [attachments]
+ );
+ const voiceAttachments = useMemo(
+ () => attachments.filter((item) => item.message_type === "voice" || item.message_type === "circle_video").sort((a, b) => b.id - a.id),
+ [attachments]
+ );
const audioAttachments = useMemo(
() =>
attachments
@@ -871,10 +883,15 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
.slice(0, 120)
.map((item) => (