From 905295ee5f11c0f0a57fcc97d3a0ebb86cffe3b6 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Thu, 18 Jul 2024 22:26:06 +0700 Subject: [PATCH] android, desktop: interactive media and link previews in the list of chats (#4460) * android, desktop: chat item content preview in chat list * better * moved code * layout * multiplier for files * small * changes * changes * changes * no padding * changes * color * multiplier * changes * fix state inconsistency in gallery * voice messages improvements * showing draft * re-layout preview * rename and padding * fix * padding * link icon * without offset * image * hand on hover * color --------- Co-authored-by: Evgeny Poberezkin --- .../common/platform/RecAndPlay.android.kt | 26 +- .../chat/simplex/common/model/ChatModel.kt | 18 ++ .../simplex/common/platform/RecAndPlay.kt | 12 + .../simplex/common/views/chat/ChatView.kt | 2 +- .../common/views/chat/ComposeVoiceView.kt | 2 +- .../common/views/chat/item/CIFileView.kt | 88 +++---- .../common/views/chat/item/CIImageView.kt | 17 +- .../common/views/chat/item/CIVIdeoView.kt | 114 +++++++-- .../common/views/chat/item/CIVoiceView.kt | 138 +++++++---- .../common/views/chat/item/ChatItemView.kt | 2 +- .../common/views/chat/item/FramedItemView.kt | 8 +- .../views/chat/item/ImageFullScreenView.kt | 9 +- .../common/views/chatlist/ChatListView.kt | 2 +- .../common/views/chatlist/ChatPreviewView.kt | 228 +++++++++++++----- .../resources/MR/images/ic_arrow_outward.svg | 1 + .../common/platform/RecAndPlay.desktop.kt | 36 +-- 16 files changed, 482 insertions(+), 221 deletions(-) create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_outward.svg diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt index 4dbc9bd9a9..e5dda23f0f 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt @@ -126,16 +126,11 @@ actual object AudioPlayer: AudioPlayerInterface { .build() ) } - // Filepath: String, onProgressUpdate - private val currentlyPlaying: MutableState Unit>?> = mutableStateOf(null) + override val currentlyPlaying: MutableState = mutableStateOf(null) private var progressJob: Job? = null - enum class TrackState { - PLAYING, PAUSED, REPLACED - } - // Returns real duration of the track - private fun start(fileSource: CryptoFile, seek: Int? = null, onProgressUpdate: (position: Int?, state: TrackState) -> Unit): Int? { + private fun start(fileSource: CryptoFile, smallView: Boolean, seek: Int? = null, onProgressUpdate: (position: Int?, state: TrackState) -> Unit): Int? { val absoluteFilePath = if (fileSource.isAbsolutePath) fileSource.filePath else getAppFilePath(fileSource.filePath) if (!File(absoluteFilePath).exists()) { Log.e(TAG, "No such file: ${fileSource.filePath}") @@ -145,7 +140,7 @@ actual object AudioPlayer: AudioPlayerInterface { VideoPlayerHolder.stopAll() RecorderInterface.stopRecording?.invoke() val current = currentlyPlaying.value - if (current == null || current.first != fileSource.filePath) { + if (current == null || current.fileSource.filePath != fileSource.filePath || smallView != current.smallView) { stopListener() player.reset() runCatching { @@ -168,7 +163,7 @@ actual object AudioPlayer: AudioPlayerInterface { } if (seek != null) player.seekTo(seek) player.start() - currentlyPlaying.value = fileSource.filePath to onProgressUpdate + currentlyPlaying.value = CurrentlyPlayingState(fileSource, onProgressUpdate, smallView) progressJob = CoroutineScope(Dispatchers.Default).launch { onProgressUpdate(player.currentPosition, TrackState.PLAYING) while(isActive && player.isPlaying) { @@ -192,6 +187,10 @@ actual object AudioPlayer: AudioPlayerInterface { } keepScreenOn(false) onProgressUpdate(null, TrackState.PAUSED) + + if (smallView && isActive) { + stopListener() + } } return player.duration } @@ -215,7 +214,7 @@ actual object AudioPlayer: AudioPlayerInterface { // FileName or filePath are ok override fun stop(fileName: String?) { - if (fileName != null && currentlyPlaying.value?.first?.endsWith(fileName) == true) { + if (fileName != null && currentlyPlaying.value?.fileSource?.filePath?.endsWith(fileName) == true) { stop() } } @@ -223,7 +222,7 @@ actual object AudioPlayer: AudioPlayerInterface { private fun stopListener() { val afterCoroutineCancel: CompletionHandler = { // Notify prev audio listener about stop - currentlyPlaying.value?.second?.invoke(null, TrackState.REPLACED) + currentlyPlaying.value?.onProgressUpdate?.invoke(null, TrackState.REPLACED) currentlyPlaying.value = null } /** Preventing race by calling a code AFTER coroutine ends, so [TrackState] will be: @@ -244,11 +243,12 @@ actual object AudioPlayer: AudioPlayerInterface { progress: MutableState, duration: MutableState, resetOnEnd: Boolean, + smallView: Boolean, ) { if (progress.value == duration.value) { progress.value = 0 } - val realDuration = start(fileSource, progress.value) { pro, state -> + val realDuration = start(fileSource, smallView, progress.value) { pro, state -> if (pro != null) { progress.value = pro } @@ -274,7 +274,7 @@ actual object AudioPlayer: AudioPlayerInterface { override fun seekTo(ms: Int, pro: MutableState, filePath: String?) { pro.value = ms - if (currentlyPlaying.value?.first == filePath) { + if (currentlyPlaying.value?.fileSource?.filePath == filePath) { player.seekTo(ms) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 5fd813f09f..e96b8a8eb1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -2765,6 +2765,24 @@ data class CIFile( is CIFileStatus.Invalid -> null } + val showStatusIconInSmallView: Boolean = when (fileStatus) { + is CIFileStatus.SndStored -> fileProtocol != FileProtocol.LOCAL + is CIFileStatus.SndTransfer -> true + is CIFileStatus.SndComplete -> false + is CIFileStatus.SndCancelled -> true + is CIFileStatus.SndError -> true + is CIFileStatus.SndWarning -> true + is CIFileStatus.RcvInvitation -> false + is CIFileStatus.RcvAccepted -> true + is CIFileStatus.RcvTransfer -> true + is CIFileStatus.RcvAborted -> true + is CIFileStatus.RcvCancelled -> true + is CIFileStatus.RcvComplete -> false + is CIFileStatus.RcvError -> true + is CIFileStatus.RcvWarning -> true + is CIFileStatus.Invalid -> true + } + /** * DO NOT CALL this function in compose scope, [LaunchedEffect], [DisposableEffect] and so on. Only with [withBGApi] or [runBlocking]. * Otherwise, it will be canceled when moving to another screen/item/view, etc diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt index 1e902b5d88..fd1824d5b6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt @@ -16,13 +16,25 @@ interface RecorderInterface { expect class RecorderNative(): RecorderInterface +enum class TrackState { + PLAYING, PAUSED, REPLACED +} + +data class CurrentlyPlayingState( + val fileSource: CryptoFile, + val onProgressUpdate: (position: Int?, state: TrackState) -> Unit, + val smallView: Boolean, +) + interface AudioPlayerInterface { + val currentlyPlaying: MutableState fun play( fileSource: CryptoFile, audioPlaying: MutableState, progress: MutableState, duration: MutableState, resetOnEnd: Boolean, + smallView: Boolean, ) fun stop() fun stop(item: ChatItem) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 5578180ff8..7af08107bb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -1416,7 +1416,7 @@ sealed class ProviderMedia { data class Video(val uri: URI, val fileSource: CryptoFile?, val preview: String): ProviderMedia() } -private fun providerForGallery( +fun providerForGallery( listStateIndex: Int, chatItems: List, cItemId: Long, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeVoiceView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeVoiceView.kt index b71d090a4e..b070dce1d1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeVoiceView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeVoiceView.kt @@ -53,7 +53,7 @@ fun ComposeVoiceView( IconButton( onClick = { if (!audioPlaying.value) { - AudioPlayer.play(CryptoFile.plain(filePath), audioPlaying, progress, duration, false) + AudioPlayer.play(CryptoFile.plain(filePath), audioPlaying, progress, duration, resetOnEnd = false, smallView = false) } else { AudioPlayer.pause(audioPlaying, progress) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt index f181126b33..59643afdf4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt @@ -1,5 +1,6 @@ package chat.simplex.common.views.chat.item +import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CornerSize @@ -12,10 +13,9 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* @@ -29,14 +29,17 @@ fun CIFileView( file: CIFile?, edited: Boolean, showMenu: MutableState, + smallView: Boolean = false, receiveFile: (Long) -> Unit ) { val saveFileLauncher = rememberSaveFileLauncher(ciFile = file) - + val sizeMultiplier = 1f + val progressSizeMultiplier = if (smallView) 0.7f else 1f @Composable fun fileIcon( innerIcon: Painter? = null, - color: Color = if (isInDarkTheme()) FileDark else FileLight + color: Color = if (isInDarkTheme()) FileDark else FileLight, + topPadding: Dp = 12.sp.toDp() ) { Box( contentAlignment = Alignment.Center @@ -52,8 +55,9 @@ fun CIFileView( innerIcon, stringResource(MR.strings.icon_descr_file), Modifier - .size(32.dp) - .padding(top = 12.dp), + .padding(top = topPadding * sizeMultiplier) + .height(20.sp.toDp() * sizeMultiplier) + .width(32.sp.toDp() * sizeMultiplier), tint = Color.White ) } @@ -132,39 +136,39 @@ fun CIFileView( fun fileIndicator() { Box( Modifier - .size(42.dp) - .clip(RoundedCornerShape(4.dp)), + .size(42.sp.toDp() * sizeMultiplier) + .clip(RoundedCornerShape(4.sp.toDp() * sizeMultiplier)), contentAlignment = Alignment.Center ) { if (file != null) { when (file.fileStatus) { is CIFileStatus.SndStored -> when (file.fileProtocol) { - FileProtocol.XFTP -> CIFileViewScope.progressIndicator() + FileProtocol.XFTP -> CIFileViewScope.progressIndicator(progressSizeMultiplier) FileProtocol.SMP -> fileIcon() FileProtocol.LOCAL -> fileIcon() } is CIFileStatus.SndTransfer -> when (file.fileProtocol) { - FileProtocol.XFTP -> CIFileViewScope.progressCircle(file.fileStatus.sndProgress, file.fileStatus.sndTotal) - FileProtocol.SMP -> CIFileViewScope.progressIndicator() + FileProtocol.XFTP -> CIFileViewScope.progressCircle(file.fileStatus.sndProgress, file.fileStatus.sndTotal, progressSizeMultiplier) + FileProtocol.SMP -> CIFileViewScope.progressIndicator(progressSizeMultiplier) FileProtocol.LOCAL -> {} } - is CIFileStatus.SndComplete -> fileIcon(innerIcon = painterResource(MR.images.ic_check_filled)) + is CIFileStatus.SndComplete -> fileIcon(innerIcon = if (!smallView) painterResource(MR.images.ic_check_filled) else null) is CIFileStatus.SndCancelled -> fileIcon(innerIcon = painterResource(MR.images.ic_close)) is CIFileStatus.SndError -> fileIcon(innerIcon = painterResource(MR.images.ic_close)) is CIFileStatus.SndWarning -> fileIcon(innerIcon = painterResource(MR.images.ic_warning_filled)) is CIFileStatus.RcvInvitation -> if (fileSizeValid(file)) - fileIcon(innerIcon = painterResource(MR.images.ic_arrow_downward), color = MaterialTheme.colors.primary) + fileIcon(innerIcon = painterResource(MR.images.ic_arrow_downward), color = MaterialTheme.colors.primary, topPadding = 10.sp.toDp()) else fileIcon(innerIcon = painterResource(MR.images.ic_priority_high), color = WarningOrange) is CIFileStatus.RcvAccepted -> fileIcon(innerIcon = painterResource(MR.images.ic_more_horiz)) is CIFileStatus.RcvTransfer -> if (file.fileProtocol == FileProtocol.XFTP && file.fileStatus.rcvProgress < file.fileStatus.rcvTotal) { - CIFileViewScope.progressCircle(file.fileStatus.rcvProgress, file.fileStatus.rcvTotal) + CIFileViewScope.progressCircle(file.fileStatus.rcvProgress, file.fileStatus.rcvTotal, progressSizeMultiplier) } else { - CIFileViewScope.progressIndicator() + CIFileViewScope.progressIndicator(progressSizeMultiplier) } is CIFileStatus.RcvAborted -> fileIcon(innerIcon = painterResource(MR.images.ic_sync_problem), color = MaterialTheme.colors.primary) @@ -186,31 +190,33 @@ fun CIFileView( onClick = { fileAction() }, onLongClick = { showMenu.value = true } ) - .padding(top = 4.dp, bottom = 6.dp, start = 6.dp, end = 12.dp), + .padding(if (smallView) PaddingValues() else PaddingValues(top = 4.sp.toDp(), bottom = 6.sp.toDp(), start = 6.sp.toDp(), end = 12.sp.toDp())), //Modifier.clickable(enabled = file?.fileSource != null) { if (file?.fileSource != null && getLoadedFilePath(file) != null) openFile(file.fileSource) }.padding(top = 4.dp, bottom = 6.dp, start = 6.dp, end = 12.dp), verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.spacedBy(2.dp) + horizontalArrangement = Arrangement.spacedBy(2.sp.toDp()) ) { fileIndicator() - val metaReserve = if (edited) - " " - else - " " - if (file != null) { - Column { - Text( - file.fileName, - maxLines = 1 - ) - Text( - formatBytes(file.fileSize) + metaReserve, - color = MaterialTheme.colors.secondary, - fontSize = 14.sp, - maxLines = 1 - ) + if (!smallView) { + val metaReserve = if (edited) + " " + else + " " + if (file != null) { + Column { + Text( + file.fileName, + maxLines = 1 + ) + Text( + formatBytes(file.fileSize) + metaReserve, + color = MaterialTheme.colors.secondary, + fontSize = 14.sp, + maxLines = 1 + ) + } + } else { + Text(metaReserve) } - } else { - Text(metaReserve) } } } @@ -243,18 +249,18 @@ fun rememberSaveFileLauncher(ciFile: CIFile?): FileChooserLauncher = object CIFileViewScope { @Composable - fun progressIndicator() { + fun progressIndicator(sizeMultiplier: Float = 1f) { CircularProgressIndicator( - Modifier.size(32.dp), + Modifier.size(32.sp.toDp() * sizeMultiplier), color = if (isInDarkTheme()) FileDark else FileLight, - strokeWidth = 3.dp + strokeWidth = 3.sp.toDp() * sizeMultiplier ) } @Composable - fun progressCircle(progress: Long, total: Long) { + fun progressCircle(progress: Long, total: Long, sizeMultiplier: Float = 1f) { val angle = 360f * (progress.toDouble() / total.toDouble()).toFloat() - val strokeWidth = with(LocalDensity.current) { 3.dp.toPx() } + val strokeWidth = with(LocalDensity.current) { 3.sp.toPx() } val strokeColor = if (isInDarkTheme()) FileDark else FileLight Surface( Modifier.drawRingModifier(angle, strokeColor, strokeWidth), @@ -262,7 +268,7 @@ object CIFileViewScope { shape = MaterialTheme.shapes.small.copy(CornerSize(percent = 50)), contentColor = LocalContentColor.current ) { - Box(Modifier.size(32.dp)) + Box(Modifier.size(32.sp.toDp() * sizeMultiplier)) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt index 5aa3bfab05..59740711fe 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt @@ -29,6 +29,7 @@ fun CIImageView( file: CIFile?, imageProvider: () -> ImageGalleryProvider, showMenu: MutableState, + smallView: Boolean, receiveFile: (Long) -> Unit ) { @Composable @@ -55,7 +56,7 @@ fun CIImageView( if (file != null) { Box( Modifier - .padding(8.dp) + .padding(if (smallView) 0.dp else 8.dp) .size(20.dp), contentAlignment = Alignment.Center ) { @@ -105,7 +106,7 @@ fun CIImageView( onClick = onClick ) .onRightClick { showMenu.value = true }, - contentScale = ContentScale.FillWidth, + contentScale = if (smallView) ContentScale.Crop else ContentScale.FillWidth, ) } @@ -128,7 +129,7 @@ fun CIImageView( onClick = onClick ) .onRightClick { showMenu.value = true }, - contentScale = ContentScale.FillWidth, + contentScale = if (smallView) ContentScale.Crop else ContentScale.FillWidth, ) } else { Box(Modifier @@ -191,7 +192,7 @@ fun CIImageView( } } else { KeyChangeEffect(file) { - if (res.value == null) { + if (res.value == null || res.value!!.third != getLoadedFilePath(file)) { res.value = imageAndFilePath(file) } } @@ -255,7 +256,13 @@ fun CIImageView( } }) } - loadingIndicator() + if (!smallView) { + loadingIndicator() + } else if (file?.showStatusIconInSmallView == true) { + Box(Modifier.align(Alignment.Center)) { + loadingIndicator() + } + } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt index e655b73b02..dd6e8a28fd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt @@ -31,6 +31,7 @@ fun CIVideoView( file: CIFile?, imageProvider: () -> ImageGalleryProvider, showMenu: MutableState, + smallView: Boolean = false, receiveFile: (Long) -> Unit ) { Box( @@ -39,6 +40,7 @@ fun CIVideoView( ) { val preview = remember(image) { base64ToBitmap(image) } val filePath = remember(file, CIFile.cachedRemoteFileRequests.toList()) { mutableStateOf(getLoadedFilePath(file)) } + val sizeMultiplier = if (smallView) 0.38f else 1f if (chatModel.connectedToRemote()) { LaunchedEffect(file) { withLongRunningApi(slow = 600_000) { @@ -63,8 +65,12 @@ fun CIVideoView( val autoPlay = remember { mutableStateOf(false) } val uriDecrypted = remember(filePath) { mutableStateOf(if (file.fileSource?.cryptoArgs == null) uri else file.fileSource.decryptedGet()) } val decrypted = uriDecrypted.value - if (decrypted != null) { + if (decrypted != null && smallView) { + SmallVideoView(decrypted, file, preview, duration * 1000L, autoPlay, sizeMultiplier, openFullscreen = openFullscreen) + } else if (decrypted != null) { VideoView(decrypted, file, preview, duration * 1000L, autoPlay, showMenu, openFullscreen = openFullscreen) + } else if (smallView) { + SmallVideoViewEncrypted(uriDecrypted, file, preview, autoPlay, showMenu, sizeMultiplier, openFullscreen = openFullscreen) } else { VideoViewEncrypted(uriDecrypted, file, preview, duration * 1000L, autoPlay, showMenu, openFullscreen = openFullscreen) } @@ -96,18 +102,25 @@ fun CIVideoView( } } }, + smallView = smallView, onLongClick = { showMenu.value = true }) - if (file != null) { + if (file != null && !smallView) { DurationProgress(file, remember { mutableStateOf(false) }, remember { mutableStateOf(duration * 1000L) }, remember { mutableStateOf(0L) }/*, soundEnabled*/) } if (file?.fileStatus is CIFileStatus.RcvInvitation || file?.fileStatus is CIFileStatus.RcvAborted) { - PlayButton(error = false, { showMenu.value = true }) { receiveFileIfValidSize(file, receiveFile) } + PlayButton(error = false, sizeMultiplier, { showMenu.value = true }) { receiveFileIfValidSize(file, receiveFile) } } } } - fileStatusIcon(file) + if (!smallView) { + fileStatusIcon(file, false) + } else if (file?.showStatusIconInSmallView == true) { + Box(Modifier.align(Alignment.Center)) { + fileStatusIcon(file, true) + } + } } } @@ -124,11 +137,11 @@ private fun VideoViewEncrypted( var decryptionInProgress by rememberSaveable(file.fileName) { mutableStateOf(false) } val onLongClick = { showMenu.value = true } Box { - VideoPreviewImageView(defaultPreview, if (decryptionInProgress) {{}} else openFullscreen, onLongClick) + VideoPreviewImageView(defaultPreview, smallView = false, if (decryptionInProgress) {{}} else openFullscreen, onLongClick) if (decryptionInProgress) { - VideoDecryptionProgress(onLongClick = onLongClick) + VideoDecryptionProgress(1f, onLongClick = onLongClick) } else { - PlayButton(false, onLongClick = onLongClick) { + PlayButton(false, 1f, onLongClick = onLongClick) { decryptionInProgress = true withBGApi { try { @@ -144,6 +157,64 @@ private fun VideoViewEncrypted( } } +@Composable +private fun SmallVideoViewEncrypted( + uriUnencrypted: MutableState, + file: CIFile, + defaultPreview: ImageBitmap, + autoPlay: MutableState, + showMenu: MutableState, + sizeMultiplier: Float, + openFullscreen: () -> Unit, +) { + var decryptionInProgress by rememberSaveable(file.fileName) { mutableStateOf(false) } + val onLongClick = { showMenu.value = true } + Box { + VideoPreviewImageView(defaultPreview, smallView = true, if (decryptionInProgress) {{}} else openFullscreen, onLongClick) + if (decryptionInProgress) { + VideoDecryptionProgress(sizeMultiplier, onLongClick = onLongClick) + } else if (!file.showStatusIconInSmallView) { + PlayButton(false, sizeMultiplier, onLongClick = onLongClick) { + decryptionInProgress = true + withBGApi { + try { + uriUnencrypted.value = file.fileSource?.decryptedGetOrCreate() + autoPlay.value = uriUnencrypted.value != null + } finally { + decryptionInProgress = false + } + } + } + } + } +} + +@Composable +private fun SmallVideoView(uri: URI, file: CIFile, defaultPreview: ImageBitmap, defaultDuration: Long, autoPlay: MutableState, sizeMultiplier: Float, openFullscreen: () -> Unit) { + val player = remember(uri) { VideoPlayerHolder.getOrCreate(uri, true, defaultPreview, defaultDuration, true) } + val preview by remember { player.preview } + // val soundEnabled by rememberSaveable(uri.path) { player.soundEnabled } + val brokenVideo by rememberSaveable(uri.path) { player.brokenVideo } + Box { + val windowWidth = LocalWindowWidth() + val width = remember(preview) { if (preview.width * 0.97 <= preview.height) videoViewFullWidth(windowWidth) * 0.75f else DEFAULT_MAX_IMAGE_WIDTH } + PlayerView( + player, + width, + onClick = openFullscreen, + onLongClick = {}, + {} + ) + VideoPreviewImageView(preview, smallView = true, openFullscreen, onLongClick = {}) + if (!file.showStatusIconInSmallView) { + PlayButton(brokenVideo, sizeMultiplier, onLongClick = {}, onClick = openFullscreen) + } + } + LaunchedEffect(uri) { + if (autoPlay.value) openFullscreen() + } +} + @Composable private fun VideoView(uri: URI, file: CIFile, defaultPreview: ImageBitmap, defaultDuration: Long, autoPlay: MutableState, showMenu: MutableState, openFullscreen: () -> Unit) { val player = remember(uri) { VideoPlayerHolder.getOrCreate(uri, false, defaultPreview, defaultDuration, true) } @@ -186,9 +257,9 @@ private fun VideoView(uri: URI, file: CIFile, defaultPreview: ImageBitmap, defau stop ) if (showPreview.value) { - VideoPreviewImageView(preview, openFullscreen, onLongClick) + VideoPreviewImageView(preview, smallView = false, openFullscreen, onLongClick) if (!autoPlay.value) { - PlayButton(brokenVideo, onLongClick = onLongClick, play) + PlayButton(brokenVideo, onLongClick = onLongClick, onClick = play) } } DurationProgress(file, videoPlaying, duration, progress/*, soundEnabled*/) @@ -199,16 +270,16 @@ private fun VideoView(uri: URI, file: CIFile, defaultPreview: ImageBitmap, defau expect fun PlayerView(player: VideoPlayer, width: Dp, onClick: () -> Unit, onLongClick: () -> Unit, stop: () -> Unit) @Composable -private fun BoxScope.PlayButton(error: Boolean = false, onLongClick: () -> Unit, onClick: () -> Unit) { +private fun BoxScope.PlayButton(error: Boolean = false, sizeMultiplier: Float = 1f, onLongClick: () -> Unit, onClick: () -> Unit) { Surface( - Modifier.align(Alignment.Center), + Modifier.align(if (sizeMultiplier != 1f) Alignment.TopStart else Alignment.Center), color = Color.Black.copy(alpha = 0.25f), shape = RoundedCornerShape(percent = 50), contentColor = LocalContentColor.current ) { Box( Modifier - .defaultMinSize(minWidth = 40.dp, minHeight = 40.dp) + .defaultMinSize(minWidth = if (sizeMultiplier != 1f) 40.sp.toDp() * sizeMultiplier else 40.sp.toDp(), minHeight = if (sizeMultiplier != 1f) 40.sp.toDp() * sizeMultiplier else 40.sp.toDp()) .combinedClickable(onClick = onClick, onLongClick = onLongClick) .onRightClick { onLongClick.invoke() }, contentAlignment = Alignment.Center @@ -216,6 +287,7 @@ private fun BoxScope.PlayButton(error: Boolean = false, onLongClick: () -> Unit, Icon( painterResource(MR.images.ic_play_arrow_filled), contentDescription = null, + Modifier.size(if (sizeMultiplier != 1f) 24.sp.toDp() * sizeMultiplier * 1.6f else 24.sp.toDp()), tint = if (error) WarningOrange else Color.White ) } @@ -223,25 +295,25 @@ private fun BoxScope.PlayButton(error: Boolean = false, onLongClick: () -> Unit, } @Composable -fun BoxScope.VideoDecryptionProgress(onLongClick: () -> Unit) { +fun BoxScope.VideoDecryptionProgress(sizeMultiplier: Float = 1f, onLongClick: () -> Unit) { Surface( - Modifier.align(Alignment.Center), + Modifier.align(if (sizeMultiplier != 1f) Alignment.TopStart else Alignment.Center), color = Color.Black.copy(alpha = 0.25f), shape = RoundedCornerShape(percent = 50), contentColor = LocalContentColor.current ) { Box( Modifier - .defaultMinSize(minWidth = 40.dp, minHeight = 40.dp) + .defaultMinSize(minWidth = if (sizeMultiplier != 1f) 40.sp.toDp() * sizeMultiplier else 40.sp.toDp(), minHeight = if (sizeMultiplier != 1f) 40.sp.toDp() * sizeMultiplier else 40.sp.toDp()) .combinedClickable(onClick = {}, onLongClick = onLongClick) .onRightClick { onLongClick.invoke() }, contentAlignment = Alignment.Center ) { CircularProgressIndicator( Modifier - .size(30.dp), + .size(if (sizeMultiplier != 1f) 30.sp.toDp() * sizeMultiplier else 30.sp.toDp()), color = Color.White, - strokeWidth = 2.5.dp + strokeWidth = 2.5.sp.toDp() * sizeMultiplier ) } } @@ -293,7 +365,7 @@ private fun DurationProgress(file: CIFile, playing: MutableState, durat } @Composable -fun VideoPreviewImageView(preview: ImageBitmap, onClick: () -> Unit, onLongClick: () -> Unit) { +fun VideoPreviewImageView(preview: ImageBitmap, smallView: Boolean, onClick: () -> Unit, onLongClick: () -> Unit) { val windowWidth = LocalWindowWidth() val width = remember(preview) { if (preview.width * 0.97 <= preview.height) videoViewFullWidth(windowWidth) * 0.75f else DEFAULT_MAX_IMAGE_WIDTH } Image( @@ -306,7 +378,7 @@ fun VideoPreviewImageView(preview: ImageBitmap, onClick: () -> Unit, onLongClick onClick = onClick ) .onRightClick(onLongClick), - contentScale = ContentScale.FillWidth, + contentScale = if (smallView) ContentScale.Crop else ContentScale.FillWidth, ) } @@ -366,11 +438,11 @@ private fun progressCircle(progress: Long, total: Long) { } @Composable -private fun fileStatusIcon(file: CIFile?) { +private fun fileStatusIcon(file: CIFile?, smallView: Boolean) { if (file != null) { Box( Modifier - .padding(8.dp) + .padding(if (smallView) 0.dp else 8.dp) .size(20.dp), contentAlignment = Alignment.Center ) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt index 040dd97474..5ae46ef4e7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt @@ -24,6 +24,7 @@ import chat.simplex.common.platform.* import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource import kotlinx.coroutines.flow.* +import kotlin.math.* // TODO refactor https://github.com/simplex-chat/simplex-chat/pull/1451#discussion_r1033429901 @@ -37,11 +38,18 @@ fun CIVoiceView( ci: ChatItem, timedMessagesTTL: Int?, showViaProxy: Boolean, + smallView: Boolean = false, longClick: () -> Unit, receiveFile: (Long) -> Unit, ) { + val sizeMultiplier = if (smallView) voiceMessageSizeBasedOnSquareSize(36f) / 56f else 1f + val padding = when { + smallView -> PaddingValues() + hasText -> PaddingValues(top = 14.sp.toDp() * sizeMultiplier, bottom = 14.sp.toDp() * sizeMultiplier, start = 6.sp.toDp() * sizeMultiplier, end = 6.sp.toDp() * sizeMultiplier) + else -> PaddingValues(top = 4.sp.toDp() * sizeMultiplier, bottom = 6.sp.toDp() * sizeMultiplier, start = 0.dp, end = 0.dp) + } Row( - Modifier.padding(top = if (hasText) 14.dp else 4.dp, bottom = if (hasText) 14.dp else 6.dp, start = if (hasText) 6.dp else 0.dp, end = if (hasText) 6.dp else 0.dp), + Modifier.padding(padding), verticalAlignment = Alignment.CenterVertically ) { if (file != null) { @@ -54,7 +62,7 @@ fun CIVoiceView( val play: () -> Unit = { val playIfExists = { if (fileSource.value != null) { - AudioPlayer.play(fileSource.value!!, audioPlaying, progress, duration, true) + AudioPlayer.play(fileSource.value!!, audioPlaying, progress, duration, resetOnEnd = true, smallView = smallView) brokenAudio = !audioPlaying.value } } @@ -69,7 +77,7 @@ fun CIVoiceView( val pause = { AudioPlayer.pause(audioPlaying, progress) } - val text = remember { + val text = remember(ci.file?.fileId, ci.file?.fileStatus) { derivedStateOf { val time = when { audioPlaying.value || progress.value != 0 -> progress.value @@ -78,11 +86,18 @@ fun CIVoiceView( durationText(time / 1000) } } - VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, showViaProxy, play, pause, longClick, receiveFile) { + VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, showViaProxy, sizeMultiplier, play, pause, longClick, receiveFile) { AudioPlayer.seekTo(it, progress, fileSource.value?.filePath) } + if (smallView) { + KeyChangeEffect(chatModel.chatId.value, chatModel.currentUser.value?.userId, chatModel.currentRemoteHost.value) { + AudioPlayer.stop() + } + } + } else if (smallView) { + VoiceMsgIndicator(null, false, sent, hasText, null, null, false, sizeMultiplier, {}, {}, longClick, receiveFile) } else { - VoiceMsgIndicator(null, false, sent, hasText, null, null, false, {}, {}, longClick, receiveFile) + VoiceMsgIndicator(null, false, sent, hasText, null, null, false, 1f, {}, {}, longClick, receiveFile) val metaReserve = if (edited) " " else @@ -105,6 +120,7 @@ private fun VoiceLayout( hasText: Boolean, timedMessagesTTL: Int?, showViaProxy: Boolean, + sizeMultiplier: Float, play: () -> Unit, pause: () -> Unit, longClick: () -> Unit, @@ -116,15 +132,16 @@ private fun VoiceLayout( var movedManuallyTo by rememberSaveable(file.fileId) { mutableStateOf(-1) } if (audioPlaying.value || progress.value > 0 || movedManuallyTo == progress.value) { val dp4 = with(LocalDensity.current) { 4.dp.toPx() } - val dp10 = with(LocalDensity.current) { 10.dp.toPx() } val primary = MaterialTheme.colors.primary val inactiveTrackColor = MaterialTheme.colors.primary.mixWith( backgroundColor.copy(1f).mixWith(MaterialTheme.colors.background, backgroundColor.alpha), 0.24f) val width = LocalWindowWidth() + // Built-in slider has rounded corners but we need square corners, so drawing a track manually val colors = SliderDefaults.colors( - inactiveTrackColor = inactiveTrackColor + inactiveTrackColor = Color.Transparent, + activeTrackColor = Color.Transparent ) Slider( progress.value.toFloat(), @@ -133,12 +150,12 @@ private fun VoiceLayout( movedManuallyTo = it.toInt() }, Modifier - .size(width, 48.dp) + .size(width, 48.sp.toDp()) .weight(1f) .padding(padding) .drawBehind { - drawRect(primary, Offset(0f, (size.height - dp4) / 2), size = androidx.compose.ui.geometry.Size(dp10, dp4)) - drawRect(inactiveTrackColor, Offset(size.width - dp10, (size.height - dp4) / 2), size = androidx.compose.ui.geometry.Size(dp10, dp4)) + drawRect(inactiveTrackColor, Offset(0f, (size.height - dp4) / 2), size = Size(size.width, dp4)) + drawRect(primary, Offset(0f, (size.height - dp4) / 2), size = Size(progress.value.toFloat() / max(0.00001f, duration.value.toFloat()) * size.width, dp4)) }, valueRange = 0f..duration.value.toFloat(), colors = colors @@ -153,13 +170,22 @@ private fun VoiceLayout( } } when { + sizeMultiplier != 1f -> { + Row(verticalAlignment = Alignment.CenterVertically) { + VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, sizeMultiplier, play, pause, longClick, receiveFile) + Row(Modifier.weight(1f, false), verticalAlignment = Alignment.CenterVertically) { + DurationText(text, PaddingValues(start = 8.sp.toDp()), true) + Slider(MaterialTheme.colors.background, PaddingValues(start = 7.sp.toDp())) + } + } + } hasText -> { val sentColor = MaterialTheme.appColors.sentMessage val receivedColor = MaterialTheme.appColors.receivedMessage - Spacer(Modifier.width(6.dp)) - VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick, receiveFile) + Spacer(Modifier.width(6.sp.toDp() * sizeMultiplier)) + VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, 1f, play, pause, longClick, receiveFile) Row(verticalAlignment = Alignment.CenterVertically) { - DurationText(text, PaddingValues(start = 12.dp)) + DurationText(text, PaddingValues(start = 12.sp.toDp() * sizeMultiplier)) Slider(if (ci.chatDir.sent) sentColor else receivedColor) } } @@ -167,13 +193,13 @@ private fun VoiceLayout( Column(horizontalAlignment = Alignment.End) { Row { Row(Modifier.weight(1f, false), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.End) { - Spacer(Modifier.height(56.dp)) + Spacer(Modifier.height(56.sp.toDp() * sizeMultiplier)) Slider(MaterialTheme.colors.background, PaddingValues(end = DEFAULT_PADDING_HALF + 3.dp)) - DurationText(text, PaddingValues(end = 12.dp)) + DurationText(text, PaddingValues(end = 12.sp.toDp() * sizeMultiplier)) } - VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick, receiveFile) + VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, 1f, play, pause, longClick, receiveFile) } - Box(Modifier.padding(top = 6.dp, end = 6.dp)) { + Box(Modifier.padding(top = 6.sp.toDp() * sizeMultiplier, end = 6.sp.toDp() * sizeMultiplier)) { CIMetaView(ci, timedMessagesTTL, showViaProxy = showViaProxy) } } @@ -181,14 +207,14 @@ private fun VoiceLayout( else -> { Column(horizontalAlignment = Alignment.Start) { Row { - VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick, receiveFile) + VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, 1f, play, pause, longClick, receiveFile) Row(Modifier.weight(1f, false), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start) { - DurationText(text, PaddingValues(start = 12.dp)) + DurationText(text, PaddingValues(start = 12.sp.toDp() * sizeMultiplier)) Slider(MaterialTheme.colors.background, PaddingValues(start = DEFAULT_PADDING_HALF + 3.dp)) - Spacer(Modifier.height(56.dp)) + Spacer(Modifier.height(56.sp.toDp() * sizeMultiplier)) } } - Box(Modifier.padding(top = 6.dp)) { + Box(Modifier.padding(top = 6.sp.toDp() * sizeMultiplier)) { CIMetaView(ci, timedMessagesTTL, showViaProxy = showViaProxy) } } @@ -197,7 +223,7 @@ private fun VoiceLayout( } @Composable -private fun DurationText(text: State, padding: PaddingValues) { +private fun DurationText(text: State, padding: PaddingValues, smallView: Boolean = false) { val minWidth = with(LocalDensity.current) { 45.sp.toDp() } Text( text.value, @@ -205,7 +231,7 @@ private fun DurationText(text: State, padding: PaddingValues) { .padding(padding) .widthIn(min = minWidth), color = MaterialTheme.colors.secondary, - fontSize = 16.sp, + fontSize = if (smallView) 15.sp else 16.sp, maxLines = 1 ) } @@ -219,6 +245,7 @@ private fun PlayPauseButton( strokeColor: Color, enabled: Boolean, error: Boolean, + sizeMultiplier: Float = 1f, play: () -> Unit, pause: () -> Unit, longClick: () -> Unit, @@ -234,7 +261,7 @@ private fun PlayPauseButton( ) { Box( Modifier - .defaultMinSize(minWidth = 56.dp, minHeight = 56.dp) + .defaultMinSize(minWidth = 56.sp.toDp() * sizeMultiplier, minHeight = 56.sp.toDp() * sizeMultiplier) .combinedClickable( onClick = { if (!audioPlaying) play() else pause() }, onLongClick = longClick @@ -245,7 +272,7 @@ private fun PlayPauseButton( Icon( if (audioPlaying) painterResource(MR.images.ic_pause_filled) else painterResource(icon), contentDescription = null, - Modifier.size(36.dp), + Modifier.size(36.sp.toDp() * sizeMultiplier), tint = if (error) WarningOrange else if (!enabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary ) } @@ -262,34 +289,42 @@ private fun PlayablePlayPauseButton( strokeWidth: Float, strokeColor: Color, error: Boolean, + sizeMultiplier: Float = 1f, play: () -> Unit, pause: () -> Unit, longClick: () -> Unit, ) { val angle = 360f * (progress.value.toDouble() / duration.value).toFloat() if (hasText) { - IconButton({ if (!audioPlaying) play() else pause() }, Modifier.size(56.dp).drawRingModifier(angle, strokeColor, strokeWidth)) { + Box( + Modifier + .defaultMinSize(minWidth = 56.sp.toDp() * sizeMultiplier, minHeight = 56.sp.toDp() * sizeMultiplier) + .clip(MaterialTheme.shapes.small.copy(CornerSize(percent = 50))) + .combinedClickable(onClick = { if (!audioPlaying) play() else pause() } ) + .drawRingModifier(angle, strokeColor, strokeWidth), + contentAlignment = Alignment.Center + ) { Icon( if (audioPlaying) painterResource(MR.images.ic_pause_filled) else painterResource(MR.images.ic_play_arrow_filled), contentDescription = null, - Modifier.size(36.dp), + Modifier.size(36.sp.toDp() * sizeMultiplier), tint = MaterialTheme.colors.primary ) } } else { - PlayPauseButton(audioPlaying, sent, angle, strokeWidth, strokeColor, true, error, play, pause, longClick = longClick) + PlayPauseButton(audioPlaying, sent, angle, strokeWidth, strokeColor, true, error, sizeMultiplier, play, pause, longClick = longClick) } } @Composable -private fun VoiceMsgLoadingProgressIndicator() { +private fun VoiceMsgLoadingProgressIndicator(sizeMultiplier: Float) { Box( Modifier - .size(56.dp) - .clip(RoundedCornerShape(4.dp)), + .size(56.sp.toDp() * sizeMultiplier) + .clip(RoundedCornerShape(4.sp.toDp() * sizeMultiplier)), contentAlignment = Alignment.Center ) { - ProgressIndicator() + ProgressIndicator(sizeMultiplier) } } @@ -297,6 +332,7 @@ private fun VoiceMsgLoadingProgressIndicator() { private fun FileStatusIcon( sent: Boolean, icon: ImageResource, + sizeMultiplier: Float, longClick: () -> Unit, onClick: () -> Unit, ) { @@ -309,7 +345,7 @@ private fun FileStatusIcon( ) { Box( Modifier - .defaultMinSize(minWidth = 56.dp, minHeight = 56.dp) + .defaultMinSize(minWidth = 56.sp.toDp() * sizeMultiplier, minHeight = 56.sp.toDp() * sizeMultiplier) .combinedClickable( onClick = onClick, onLongClick = longClick @@ -320,7 +356,7 @@ private fun FileStatusIcon( Icon( painterResource(icon), contentDescription = null, - Modifier.size(36.dp), + Modifier.size(36.sp.toDp() * sizeMultiplier), tint = MaterialTheme.colors.secondary ) } @@ -336,26 +372,28 @@ private fun VoiceMsgIndicator( progress: State?, duration: State?, error: Boolean, + sizeMultiplier: Float, play: () -> Unit, pause: () -> Unit, longClick: () -> Unit, receiveFile: (Long) -> Unit, ) { - val strokeWidth = with(LocalDensity.current) { 3.dp.toPx() } + val strokeWidth = with(LocalDensity.current) { 3.sp.toPx() } * sizeMultiplier val strokeColor = MaterialTheme.colors.primary when { file?.fileStatus is CIFileStatus.SndStored -> if (file.fileProtocol == FileProtocol.LOCAL && progress != null && duration != null) { - PlayablePlayPauseButton(audioPlaying, sent, hasText, progress, duration, strokeWidth, strokeColor, error, play, pause, longClick = longClick) + PlayablePlayPauseButton(audioPlaying, sent, hasText, progress, duration, strokeWidth, strokeColor, error, sizeMultiplier, play, pause, longClick = longClick) } else { - VoiceMsgLoadingProgressIndicator() + VoiceMsgLoadingProgressIndicator(sizeMultiplier) } file?.fileStatus is CIFileStatus.SndTransfer -> - VoiceMsgLoadingProgressIndicator() + VoiceMsgLoadingProgressIndicator(sizeMultiplier) file != null && file.fileStatus is CIFileStatus.SndError -> FileStatusIcon( sent, MR.images.ic_close, + sizeMultiplier, longClick, onClick = { AlertManager.shared.showAlertMsg( @@ -368,6 +406,7 @@ private fun VoiceMsgIndicator( FileStatusIcon( sent, MR.images.ic_warning_filled, + sizeMultiplier, longClick, onClick = { AlertManager.shared.showAlertMsg( @@ -377,15 +416,16 @@ private fun VoiceMsgIndicator( } ) file?.fileStatus is CIFileStatus.RcvInvitation -> - PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, true, error, { receiveFile(file.fileId) }, {}, longClick = longClick) + PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, true, error, sizeMultiplier, { receiveFile(file.fileId) }, {}, longClick = longClick) file?.fileStatus is CIFileStatus.RcvTransfer || file?.fileStatus is CIFileStatus.RcvAccepted -> - VoiceMsgLoadingProgressIndicator() + VoiceMsgLoadingProgressIndicator(sizeMultiplier) file?.fileStatus is CIFileStatus.RcvAborted -> - PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, true, error, { receiveFile(file.fileId) }, {}, longClick = longClick, icon = MR.images.ic_sync_problem) + PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, true, error, sizeMultiplier, { receiveFile(file.fileId) }, {}, longClick = longClick, icon = MR.images.ic_sync_problem) file != null && file.fileStatus is CIFileStatus.RcvError -> FileStatusIcon( sent, MR.images.ic_close, + sizeMultiplier, longClick, onClick = { AlertManager.shared.showAlertMsg( @@ -398,6 +438,7 @@ private fun VoiceMsgIndicator( FileStatusIcon( sent, MR.images.ic_warning_filled, + sizeMultiplier, longClick, onClick = { AlertManager.shared.showAlertMsg( @@ -407,9 +448,9 @@ private fun VoiceMsgIndicator( } ) file != null && file.loaded && progress != null && duration != null -> - PlayablePlayPauseButton(audioPlaying, sent, hasText, progress, duration, strokeWidth, strokeColor, error, play, pause, longClick = longClick) + PlayablePlayPauseButton(audioPlaying, sent, hasText, progress, duration, strokeWidth, strokeColor, error, sizeMultiplier, play, pause, longClick = longClick) else -> - PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, false, false, {}, {}, longClick) + PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, false, false, sizeMultiplier, {}, {}, longClick) } } @@ -435,11 +476,16 @@ fun Modifier.drawRingModifier(angle: Float, color: Color, strokeWidth: Float) = } } +fun voiceMessageSizeBasedOnSquareSize(squareSize: Float): Float { + val squareToCircleRatio = 0.935f + return squareSize + squareSize * (1 - squareToCircleRatio) +} + @Composable -private fun ProgressIndicator() { +private fun ProgressIndicator(sizeMultiplier: Float) { CircularProgressIndicator( - Modifier.size(32.dp), + Modifier.size(32.sp.toDp() * sizeMultiplier), color = if (isInDarkTheme()) FileDark else FileLight, - strokeWidth = 4.dp + strokeWidth = 4.sp.toDp() * sizeMultiplier ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index c195a1a299..6f5cb63262 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -341,7 +341,7 @@ fun ChatItemView( if (mc is MsgContent.MCText && isShortEmoji(cItem.content.text)) { EmojiItemView(cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy) } else if (mc is MsgContent.MCVoice && cItem.content.text.isEmpty()) { - CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, longClick = { onLinkLongClick("") }, receiveFile) + CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, longClick = { onLinkLongClick("") }, receiveFile = receiveFile) } else { framedItemView() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index b2777a7042..16d2a2dad2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -176,7 +176,7 @@ fun FramedItemView( @Composable fun ciFileView(ci: ChatItem, text: String) { - CIFileView(ci.file, ci.meta.itemEdited, showMenu, receiveFile) + CIFileView(ci.file, ci.meta.itemEdited, showMenu, false, receiveFile) if (text != "" || ci.meta.isLive) { CIMarkdownText(ci, chatTTL, linkMode = linkMode, uriHandler, showViaProxy = showViaProxy) } @@ -238,7 +238,7 @@ fun FramedItemView( } else { when (val mc = ci.content.msgContent) { is MsgContent.MCImage -> { - CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, receiveFile) + CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, false, receiveFile) if (mc.text == "" && !ci.meta.isLive) { metaColor = Color.White } else { @@ -246,7 +246,7 @@ fun FramedItemView( } } is MsgContent.MCVideo -> { - CIVideoView(image = mc.image, mc.duration, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, receiveFile) + CIVideoView(image = mc.image, mc.duration, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, smallView = false, receiveFile = receiveFile) if (mc.text == "" && !ci.meta.isLive) { metaColor = Color.White } else { @@ -254,7 +254,7 @@ fun FramedItemView( } } is MsgContent.MCVoice -> { - CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = true, ci, timedMessagesTTL = chatTTL, showViaProxy = showViaProxy, longClick = { onLinkLongClick("") }, receiveFile) + CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = true, ci, timedMessagesTTL = chatTTL, showViaProxy = showViaProxy, longClick = { onLinkLongClick("") }, receiveFile = receiveFile) if (mc.text != "") { CIMarkdownText(ci, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt index 1dd5e4ee69..09838796c5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt @@ -16,7 +16,6 @@ import chat.simplex.common.model.CryptoFile import chat.simplex.common.platform.* import chat.simplex.common.views.chat.ProviderMedia import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import java.net.URI @@ -40,14 +39,17 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () -> ) { provider.totalMediaSize.value } + val firstValidPageBeforeScrollingToStart = remember { mutableStateOf(0) } val goBack = { provider.onDismiss(pagerState.currentPage); close() } BackHandler(onBack = goBack) // Pager doesn't ask previous page at initialization step who knows why. By not doing this, prev page is not checked and can be blank, // which makes this blank page visible for a moment. Prevent it by doing the check ourselves LaunchedEffect(Unit) { if (provider.getMedia(provider.initialIndex - 1) == null) { + firstValidPageBeforeScrollingToStart.value = provider.initialIndex provider.scrollToStart() pagerState.scrollToPage(0) + firstValidPageBeforeScrollingToStart.value = 0 } } val scope = rememberCoroutineScope() @@ -58,6 +60,9 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () -> @Composable fun Content(index: Int) { + // Index can be huge but in reality at that moment pager state scrolls to 0 and that page should have index 0 too if it's the first one. + // Or index 1 if it's the second page + val index = index - firstValidPageBeforeScrollingToStart.value Column( Modifier .fillMaxSize() @@ -174,7 +179,7 @@ private fun VideoViewEncrypted(uriUnencrypted: MutableState, fileSource: C } Box(contentAlignment = Alignment.Center) { VideoPreviewImageViewFullScreen(defaultPreview, {}, {}) - VideoDecryptionProgress {} + VideoDecryptionProgress() {} } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index bf3e7774c7..5759877b07 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -518,7 +518,7 @@ private fun ChatList(chatModel: ChatModel, searchText: MutableState + itemsIndexed(chats, key = { _, chat -> chat.remoteHostId to chat.id }) { index, chat -> val nextChatSelected = remember(chat.id, chats) { derivedStateOf { chatModel.chatId.value != null && chats.getOrNull(index + 1)?.id == chatModel.chatId.value } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index c42b7e3ec3..3f66c7d7d8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -1,6 +1,5 @@ package chat.simplex.common.views.chatlist -import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.InlineTextContent @@ -15,19 +14,22 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.style.* import androidx.compose.ui.unit.* import chat.simplex.common.ui.theme.* -import chat.simplex.common.views.chat.ComposePreview -import chat.simplex.common.views.chat.ComposeState -import chat.simplex.common.views.chat.item.MarkdownText import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* import chat.simplex.common.model.GroupInfo -import chat.simplex.common.platform.appPlatform -import chat.simplex.common.platform.chatModel -import chat.simplex.common.views.chat.item.markedDeletedText +import chat.simplex.common.platform.* +import chat.simplex.common.views.chat.* +import chat.simplex.common.views.chat.item.* import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource @@ -225,6 +227,50 @@ fun ChatPreviewView( } } + @Composable + fun chatItemContentPreview(chat: Chat, ci: ChatItem?) { + val mc = ci?.content?.msgContent + val provider by remember(chat.id, ci?.id, ci?.file?.fileStatus) { + mutableStateOf({ providerForGallery(0, chat.chatItems, ci?.id ?: 0) {} }) + } + val uriHandler = LocalUriHandler.current + when (mc) { + is MsgContent.MCLink -> SmallContentPreview { + IconButton({ uriHandler.openUriCatching(mc.preview.uri) }, Modifier.desktopPointerHoverIconHand()) { + Image(base64ToBitmap(mc.preview.image), null, contentScale = ContentScale.Crop) + } + Box(Modifier.align(Alignment.TopEnd).size(15.sp.toDp()).background(Color.Black.copy(0.25f), CircleShape), contentAlignment = Alignment.Center) { + Icon(painterResource(MR.images.ic_arrow_outward), null, Modifier.size(13.sp.toDp()), tint = Color.White) + } + } + is MsgContent.MCImage -> SmallContentPreview { + CIImageView(image = mc.image, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true) { + val user = chatModel.currentUser.value ?: return@CIImageView + withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) } + } + } + is MsgContent.MCVideo -> SmallContentPreview { + CIVideoView(image = mc.image, mc.duration, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true) { + val user = chatModel.currentUser.value ?: return@CIVideoView + withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) } + } + } + is MsgContent.MCVoice -> SmallContentPreviewVoice() { + CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = false, ci, cInfo.timedMessagesTTL, showViaProxy = false, smallView = true, longClick = {}) { + val user = chatModel.currentUser.value ?: return@CIVoiceView + withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) } + } + } + is MsgContent.MCFile -> SmallContentPreviewFile { + CIFileView(ci.file, false, remember { mutableStateOf(false) }, smallView = true) { + val user = chatModel.currentUser.value ?: return@CIFileView + withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) } + } + } + else -> {} + } + } + @Composable fun progressView() { CircularProgressIndicator( @@ -279,73 +325,115 @@ fun ChatPreviewView( chatPreviewImageOverlayIcon() } } - Column( - modifier = Modifier - .padding(start = 8.dp, end = 8.sp.toDp()) - .weight(1F) - ) { - chatPreviewTitle() - Row(Modifier.heightIn(min = 46.sp.toDp()).padding(top = 3.sp.toDp())) { - chatPreviewText() + Spacer(Modifier.width(8.dp)) + Column(Modifier.weight(1f)) { + Row { + Box(Modifier.weight(1f)) { + chatPreviewTitle() + } + Spacer(Modifier.width(8.sp.toDp())) + val ts = chat.chatItems.lastOrNull()?.timestampText ?: getTimestampText(chat.chatInfo.chatTs) + ChatListTimestampView(ts) } - } + Row(Modifier.heightIn(min = 46.sp.toDp()).fillMaxWidth()) { + Row(Modifier.padding(top = 3.sp.toDp()).weight(1f)) { + val activeVoicePreview: MutableState<(ActiveVoicePreview)?> = remember(chat.id) { mutableStateOf(null) } + val chat = activeVoicePreview.value?.chat ?: chat + val ci = activeVoicePreview.value?.ci ?: chat.chatItems.lastOrNull() + val mc = ci?.content?.msgContent + if ((showChatPreviews && chatModelDraftChatId != chat.id) || activeVoicePreview.value != null) { + chatItemContentPreview(chat, ci) + } + if (mc !is MsgContent.MCVoice || mc.text.isNotEmpty() || chatModelDraftChatId == chat.id) { + Box(Modifier.offset(x = if (mc is MsgContent.MCFile) -15.sp.toDp() else 0.dp)) { + chatPreviewText() + } + } + LaunchedEffect(AudioPlayer.currentlyPlaying.value, activeVoicePreview.value) { + val playing = AudioPlayer.currentlyPlaying.value + when { + playing == null -> activeVoicePreview.value = null + activeVoicePreview.value == null -> if (mc is MsgContent.MCVoice && playing.fileSource.filePath == ci.file?.fileSource?.filePath) { + activeVoicePreview.value = ActiveVoicePreview(chat, ci, mc) + } + else -> if (playing.fileSource.filePath != ci?.file?.fileSource?.filePath) { + activeVoicePreview.value = null + } + } + } + } - Box( - contentAlignment = Alignment.TopEnd - ) { - val ts = chat.chatItems.lastOrNull()?.timestampText ?: getTimestampText(chat.chatInfo.chatTs) - ChatListTimestampView(ts) - val n = chat.chatStats.unreadCount - val showNtfsIcon = !chat.chatInfo.ntfsEnabled && (chat.chatInfo is ChatInfo.Direct || chat.chatInfo is ChatInfo.Group) - if (n > 0 || chat.chatStats.unreadChat) { - Box( - Modifier.padding(top = 24.5.sp.toDp())) { - Text( - if (n > 0) unreadCountStr(n) else "", - color = Color.White, - fontSize = 10.sp, - modifier = Modifier - .background(if (disabled || showNtfsIcon) MaterialTheme.colors.secondary else MaterialTheme.colors.primaryVariant, shape = CircleShape) - .badgeLayout() - .padding(horizontal = 3.sp.toDp()) - .padding(vertical = 1.sp.toDp()) - ) + Spacer(Modifier.width(8.sp.toDp())) + + Box(Modifier.widthIn(min = 34.sp.toDp()), contentAlignment = Alignment.TopEnd) { + val n = chat.chatStats.unreadCount + val showNtfsIcon = !chat.chatInfo.ntfsEnabled && (chat.chatInfo is ChatInfo.Direct || chat.chatInfo is ChatInfo.Group) + if (n > 0 || chat.chatStats.unreadChat) { + Text( + if (n > 0) unreadCountStr(n) else "", + color = Color.White, + fontSize = 10.sp, + style = TextStyle(textAlign = TextAlign.Center), + modifier = Modifier + .offset(y = 3.sp.toDp()) + .background(if (disabled || showNtfsIcon) MaterialTheme.colors.secondary else MaterialTheme.colors.primaryVariant, shape = CircleShape) + .badgeLayout() + .padding(horizontal = 2.sp.toDp()) + .padding(vertical = 1.sp.toDp()) + ) + } else if (showNtfsIcon) { + Icon( + painterResource(MR.images.ic_notifications_off_filled), + contentDescription = generalGetString(MR.strings.notifications), + tint = MaterialTheme.colors.secondary, + modifier = Modifier + .padding(start = 2.sp.toDp()) + .size(18.sp.toDp()) + .offset(x = 2.5.sp.toDp(), y = 2.sp.toDp()) + ) + } else if (chat.chatInfo.chatSettings?.favorite == true) { + Icon( + painterResource(MR.images.ic_star_filled), + contentDescription = generalGetString(MR.strings.favorite_chat), + tint = MaterialTheme.colors.secondary, + modifier = Modifier + .size(20.sp.toDp()) + .offset(x = 2.5.sp.toDp()) + ) + } + Box( + Modifier.offset(y = 28.sp.toDp()), + contentAlignment = Alignment.Center + ) { + chatStatusImage() + } } - } else if (showNtfsIcon) { - Box( - Modifier.padding(top = 22.sp.toDp())) { - Icon( - painterResource(MR.images.ic_notifications_off_filled), - contentDescription = generalGetString(MR.strings.notifications), - tint = MaterialTheme.colors.secondary, - modifier = Modifier - .size(17.sp.toDp()) - .offset(x = 2.5.sp.toDp()) - ) - } - } else if (chat.chatInfo.chatSettings?.favorite == true) { - Box( - Modifier.padding(top = 20.sp.toDp())) { - Icon( - painterResource(MR.images.ic_star_filled), - contentDescription = generalGetString(MR.strings.favorite_chat), - tint = MaterialTheme.colors.secondary, - modifier = Modifier - .size(20.sp.toDp()) - .offset(x = 2.5.sp.toDp()) - ) - } - } - Box( - Modifier.padding(top = 46.sp.toDp()), - contentAlignment = Alignment.Center - ) { - chatStatusImage() } } } } +@Composable +private fun SmallContentPreview(content: @Composable BoxScope.() -> Unit) { + Box(Modifier.padding(top = 2.sp.toDp(), end = 8.sp.toDp()).size(36.sp.toDp()).border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(22)).clip(RoundedCornerShape(22))) { + content() + } +} + +@Composable +private fun SmallContentPreviewVoice(content: @Composable () -> Unit) { + Box(Modifier.padding(top = 2.sp.toDp(), end = 8.sp.toDp()).height(voiceMessageSizeBasedOnSquareSize(36f).sp.toDp())) { + content() + } +} + +@Composable +private fun SmallContentPreviewFile(content: @Composable () -> Unit) { + Box(Modifier.padding(top = 3.sp.toDp(), end = 8.sp.toDp()).offset(x = -8.sp.toDp(), y = -4.sp.toDp()).height(41.sp.toDp())) { + content() + } +} + @Composable fun IncognitoIcon(incognito: Boolean) { if (incognito) { @@ -390,6 +478,12 @@ fun unreadCountStr(n: Int): String { } } +private data class ActiveVoicePreview( + val chat: Chat, + val ci: ChatItem, + val mc: MsgContent.MCVoice +) + @Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_outward.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_outward.svg new file mode 100644 index 0000000000..2391aba06c --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_arrow_outward.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt index e1dba29f04..9f34891b37 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt @@ -25,18 +25,13 @@ actual class RecorderNative: RecorderInterface { } actual object AudioPlayer: AudioPlayerInterface { - val player by lazy { AudioPlayerComponent().mediaPlayer() } + private val player by lazy { AudioPlayerComponent().mediaPlayer() } - // Filepath: String, onProgressUpdate - private val currentlyPlaying: MutableState Unit>?> = mutableStateOf(null) + override val currentlyPlaying: MutableState = mutableStateOf(null) private var progressJob: Job? = null - enum class TrackState { - PLAYING, PAUSED, REPLACED - } - // Returns real duration of the track - private fun start(fileSource: CryptoFile, seek: Int? = null, onProgressUpdate: (position: Int?, state: TrackState) -> Unit): Int? { + private fun start(fileSource: CryptoFile, smallView: Boolean, seek: Int? = null, onProgressUpdate: (position: Int?, state: TrackState) -> Unit): Int? { val absoluteFilePath = if (fileSource.isAbsolutePath) fileSource.filePath else getAppFilePath(fileSource.filePath) if (!File(absoluteFilePath).exists()) { Log.e(TAG, "No such file: ${fileSource.filePath}") @@ -46,7 +41,7 @@ actual object AudioPlayer: AudioPlayerInterface { VideoPlayerHolder.stopAll() RecorderInterface.stopRecording?.invoke() val current = currentlyPlaying.value - if (current == null || current.first != fileSource || !player.status().isPlayable) { + if (current == null || current.fileSource != fileSource || !player.status().isPlayable || smallView != current.smallView) { stopListener() player.stop() runCatching { @@ -66,7 +61,7 @@ actual object AudioPlayer: AudioPlayerInterface { } if (seek != null) player.seekTo(seek) player.start() - currentlyPlaying.value = fileSource to onProgressUpdate + currentlyPlaying.value = CurrentlyPlayingState(fileSource, onProgressUpdate, smallView) progressJob = CoroutineScope(Dispatchers.Default).launch { onProgressUpdate(player.currentPosition, TrackState.PLAYING) while(isActive && (player.isPlaying || player.status().state() == State.OPENING)) { @@ -80,7 +75,11 @@ actual object AudioPlayer: AudioPlayerInterface { onProgressUpdate(player.currentPosition, TrackState.PLAYING) } onProgressUpdate(null, TrackState.PAUSED) - currentlyPlaying.value?.first?.deleteTmpFile() + currentlyPlaying.value?.fileSource?.deleteTmpFile() + // Since coroutine is still NOT canceled, means player ended (no stop/no pause). + if (smallView && isActive) { + stopListener() + } } return player.duration } @@ -103,7 +102,7 @@ actual object AudioPlayer: AudioPlayerInterface { // FileName or filePath are ok override fun stop(fileName: String?) { - if (fileName != null && currentlyPlaying.value?.first?.filePath?.endsWith(fileName) == true) { + if (fileName != null && currentlyPlaying.value?.fileSource?.filePath?.endsWith(fileName) == true) { stop() } } @@ -111,8 +110,8 @@ actual object AudioPlayer: AudioPlayerInterface { private fun stopListener() { val afterCoroutineCancel: CompletionHandler = { // Notify prev audio listener about stop - currentlyPlaying.value?.second?.invoke(null, TrackState.REPLACED) - currentlyPlaying.value?.first?.deleteTmpFile() + currentlyPlaying.value?.onProgressUpdate?.invoke(null, TrackState.REPLACED) + currentlyPlaying.value?.fileSource?.deleteTmpFile() currentlyPlaying.value = null } /** Preventing race by calling a code AFTER coroutine ends, so [TrackState] will be: @@ -133,11 +132,12 @@ actual object AudioPlayer: AudioPlayerInterface { progress: MutableState, duration: MutableState, resetOnEnd: Boolean, + smallView: Boolean, ) { if (progress.value == duration.value) { progress.value = 0 } - val realDuration = start(fileSource, progress.value) { pro, state -> + val realDuration = start(fileSource, smallView = smallView, progress.value) { pro, state -> if (pro != null) { progress.value = pro } @@ -162,7 +162,7 @@ actual object AudioPlayer: AudioPlayerInterface { override fun seekTo(ms: Int, pro: MutableState, filePath: String?) { pro.value = ms - if (currentlyPlaying.value?.first?.filePath == filePath) { + if (currentlyPlaying.value?.fileSource?.filePath == filePath) { player.seekTo(ms) } } @@ -217,7 +217,7 @@ actual object SoundPlayer: SoundPlayerInterface { playing = true scope.launch { while (playing && sound) { - AudioPlayer.play(CryptoFile.plain(tmpFile.absolutePath), mutableStateOf(true), mutableStateOf(0), mutableStateOf(0), true) + AudioPlayer.play(CryptoFile.plain(tmpFile.absolutePath), mutableStateOf(true), mutableStateOf(0), mutableStateOf(0), resetOnEnd = true, smallView = false) delay(3500) } } @@ -239,7 +239,7 @@ actual object CallSoundsPlayer: CallSoundsPlayerInterface { SoundPlayer::class.java.getResource(soundPath)!!.openStream()!!.use { it.copyTo(tmpFile.outputStream()) } playingJob = scope.launch { while (isActive) { - AudioPlayer.play(CryptoFile.plain(tmpFile.absolutePath), mutableStateOf(true), mutableStateOf(0), mutableStateOf(0), true) + AudioPlayer.play(CryptoFile.plain(tmpFile.absolutePath), mutableStateOf(true), mutableStateOf(0), mutableStateOf(0), resetOnEnd = true, smallView = false) delay(delay) } }