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 <evgeny@poberezkin.com>
This commit is contained in:
Stanislav Dmitrenko
2024-07-18 22:26:06 +07:00
committed by GitHub
parent a1e707ac1b
commit 905295ee5f
16 changed files with 482 additions and 221 deletions

View File

@@ -126,16 +126,11 @@ actual object AudioPlayer: AudioPlayerInterface {
.build()
)
}
// Filepath: String, onProgressUpdate
private val currentlyPlaying: MutableState<Pair<String, (position: Int?, state: TrackState) -> Unit>?> = mutableStateOf(null)
override val currentlyPlaying: MutableState<CurrentlyPlayingState?> = 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<Int>,
duration: MutableState<Int>,
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<Int>, filePath: String?) {
pro.value = ms
if (currentlyPlaying.value?.first == filePath) {
if (currentlyPlaying.value?.fileSource?.filePath == filePath) {
player.seekTo(ms)
}
}

View File

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

View File

@@ -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<CurrentlyPlayingState?>
fun play(
fileSource: CryptoFile,
audioPlaying: MutableState<Boolean>,
progress: MutableState<Int>,
duration: MutableState<Int>,
resetOnEnd: Boolean,
smallView: Boolean,
)
fun stop()
fun stop(item: ChatItem)

View File

@@ -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<ChatItem>,
cItemId: Long,

View File

@@ -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)
}

View File

@@ -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<Boolean>,
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))
}
}
}

View File

@@ -29,6 +29,7 @@ fun CIImageView(
file: CIFile?,
imageProvider: () -> ImageGalleryProvider,
showMenu: MutableState<Boolean>,
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()
}
}
}
}

View File

@@ -31,6 +31,7 @@ fun CIVideoView(
file: CIFile?,
imageProvider: () -> ImageGalleryProvider,
showMenu: MutableState<Boolean>,
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<URI?>,
file: CIFile,
defaultPreview: ImageBitmap,
autoPlay: MutableState<Boolean>,
showMenu: MutableState<Boolean>,
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<Boolean>, 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<Boolean>, showMenu: MutableState<Boolean>, 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<Boolean>, 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
) {

View File

@@ -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<String>, padding: PaddingValues) {
private fun DurationText(text: State<String>, 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<String>, 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<Int>?,
duration: State<Int>?,
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
)
}

View File

@@ -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()
}

View File

@@ -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)
}

View File

@@ -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<URI?>, fileSource: C
}
Box(contentAlignment = Alignment.Center) {
VideoPreviewImageViewFullScreen(defaultPreview, {}, {})
VideoDecryptionProgress {}
VideoDecryptionProgress() {}
}
}

View File

@@ -518,7 +518,7 @@ private fun ChatList(chatModel: ChatModel, searchText: MutableState<TextFieldVal
Divider()
}
}
itemsIndexed(chats) { index, chat ->
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
} }

View File

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

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 -960 960 960" width="20px" fill="#000000"><path d="m255-200-91-91 377-377H224v-128h536v536H632v-317L255-200Z"/></svg>

After

Width:  |  Height:  |  Size: 182 B

View File

@@ -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<Pair<CryptoFile, (position: Int?, state: TrackState) -> Unit>?> = mutableStateOf(null)
override val currentlyPlaying: MutableState<CurrentlyPlayingState?> = 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<Int>,
duration: MutableState<Int>,
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<Int>, 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)
}
}