mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-03-30 16:25:57 +00:00
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:
committed by
GitHub
parent
a1e707ac1b
commit
905295ee5f
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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() {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
} }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 |
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user