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) => (