mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-04-25 14:12:27 +00:00
desktop: video and audio players (#3052)
* desktop: video and audio players * making player working without preinstalled VLC * mac support * don't use vlc lib when not needed * updated jna version * changes in script * video player lazy loading * mac script changes * updated build gradle for preserving atrributes of file while copying * apply the same file stats on libs to make VLC checker happy * updated script * changes --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
6de0ed4766
commit
2d7655281f
@@ -7,13 +7,12 @@ import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.font.*
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.call.*
|
||||
import chat.simplex.common.views.chat.ComposeState
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.onboarding.OnboardingStage
|
||||
import chat.simplex.common.platform.AudioPlayer
|
||||
import chat.simplex.common.platform.chatController
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.ImageResource
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
@@ -2113,6 +2112,23 @@ data class CryptoFile(
|
||||
val isAbsolutePath: Boolean
|
||||
get() = File(filePath).isAbsolute
|
||||
|
||||
@Transient
|
||||
private var tmpFile: File? = null
|
||||
|
||||
fun createTmpFileIfNeeded(): File {
|
||||
if (tmpFile == null) {
|
||||
val tmpFile = File(tmpDir, UUID.randomUUID().toString())
|
||||
tmpFile.deleteOnExit()
|
||||
ChatModel.filesToDelete.add(tmpFile)
|
||||
this.tmpFile = tmpFile
|
||||
}
|
||||
return tmpFile!!
|
||||
}
|
||||
|
||||
fun deleteTmpFile() {
|
||||
tmpFile?.delete()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun plain(f: String): CryptoFile = CryptoFile(f, null)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import java.net.URI
|
||||
interface VideoPlayerInterface {
|
||||
data class PreviewAndDuration(val preview: ImageBitmap?, val duration: Long?, val timestamp: Long)
|
||||
|
||||
val uri: URI
|
||||
val gallery: Boolean
|
||||
val soundEnabled: MutableState<Boolean>
|
||||
val brokenVideo: MutableState<Boolean>
|
||||
val videoPlaying: MutableState<Boolean>
|
||||
@@ -20,18 +22,45 @@ interface VideoPlayerInterface {
|
||||
fun release(remove: Boolean)
|
||||
}
|
||||
|
||||
expect class VideoPlayer: VideoPlayerInterface {
|
||||
companion object {
|
||||
fun getOrCreate(
|
||||
uri: URI,
|
||||
gallery: Boolean,
|
||||
defaultPreview: ImageBitmap,
|
||||
defaultDuration: Long,
|
||||
soundEnabled: Boolean
|
||||
): VideoPlayer
|
||||
fun enableSound(enable: Boolean, fileName: String?, gallery: Boolean): Boolean
|
||||
fun release(uri: URI, gallery: Boolean, remove: Boolean)
|
||||
fun stopAll()
|
||||
fun releaseAll()
|
||||
expect class VideoPlayer(
|
||||
uri: URI,
|
||||
gallery: Boolean,
|
||||
defaultPreview: ImageBitmap,
|
||||
defaultDuration: Long,
|
||||
soundEnabled: Boolean
|
||||
): VideoPlayerInterface
|
||||
|
||||
object VideoPlayerHolder {
|
||||
val players: MutableMap<Pair<URI, Boolean>, VideoPlayer> = mutableMapOf()
|
||||
val previewsAndDurations: MutableMap<URI, VideoPlayerInterface.PreviewAndDuration> = mutableMapOf()
|
||||
|
||||
fun getOrCreate(
|
||||
uri: URI,
|
||||
gallery: Boolean,
|
||||
defaultPreview: ImageBitmap,
|
||||
defaultDuration: Long,
|
||||
soundEnabled: Boolean
|
||||
): VideoPlayer =
|
||||
players.getOrPut(uri to gallery) { VideoPlayer(uri, gallery, defaultPreview, defaultDuration, soundEnabled) }
|
||||
|
||||
fun enableSound(enable: Boolean, fileName: String?, gallery: Boolean): Boolean =
|
||||
player(fileName, gallery)?.enableSound(enable) == true
|
||||
|
||||
private fun player(fileName: String?, gallery: Boolean): VideoPlayer? {
|
||||
fileName ?: return null
|
||||
return players.values.firstOrNull { player -> player.uri.path?.endsWith(fileName) == true && player.gallery == gallery }
|
||||
}
|
||||
|
||||
fun release(uri: URI, gallery: Boolean, remove: Boolean) =
|
||||
player(uri.path, gallery)?.release(remove).run { }
|
||||
|
||||
fun stopAll() {
|
||||
players.values.forEach { it.stop() }
|
||||
}
|
||||
|
||||
fun releaseAll() {
|
||||
players.values.forEach { it.release(false) }
|
||||
players.clear()
|
||||
previewsAndDurations.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -737,7 +737,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
}
|
||||
DisposableEffectOnGone(
|
||||
whenGone = {
|
||||
VideoPlayer.releaseAll()
|
||||
VideoPlayerHolder.releaseAll()
|
||||
}
|
||||
)
|
||||
LazyColumn(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) {
|
||||
|
||||
@@ -50,7 +50,7 @@ fun CIVideoView(
|
||||
})
|
||||
} else {
|
||||
Box {
|
||||
ImageView(preview, showMenu, onClick = {
|
||||
VideoPreviewImageView(preview, onClick = {
|
||||
if (file != null) {
|
||||
when (file.fileStatus) {
|
||||
CIFileStatus.RcvInvitation ->
|
||||
@@ -75,7 +75,10 @@ fun CIVideoView(
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
onLongClick = {
|
||||
showMenu.value = true
|
||||
})
|
||||
if (file != null) {
|
||||
DurationProgress(file, remember { mutableStateOf(false) }, remember { mutableStateOf(duration * 1000L) }, remember { mutableStateOf(0L) }/*, soundEnabled*/)
|
||||
}
|
||||
@@ -90,7 +93,7 @@ fun CIVideoView(
|
||||
|
||||
@Composable
|
||||
private fun VideoView(uri: URI, file: CIFile, defaultPreview: ImageBitmap, defaultDuration: Long, showMenu: MutableState<Boolean>, onClick: () -> Unit) {
|
||||
val player = remember(uri) { VideoPlayer.getOrCreate(uri, false, defaultPreview, defaultDuration, true) }
|
||||
val player = remember(uri) { VideoPlayerHolder.getOrCreate(uri, false, defaultPreview, defaultDuration, true) }
|
||||
val videoPlaying = remember(uri.path) { player.videoPlaying }
|
||||
val progress = remember(uri.path) { player.progress }
|
||||
val duration = remember(uri.path) { player.duration }
|
||||
@@ -111,6 +114,7 @@ private fun VideoView(uri: URI, file: CIFile, defaultPreview: ImageBitmap, defau
|
||||
stop()
|
||||
}
|
||||
}
|
||||
val onLongClick = { showMenu.value = true }
|
||||
Box {
|
||||
val windowWidth = LocalWindowWidth()
|
||||
val width = remember(preview) { if (preview.width * 0.97 <= preview.height) videoViewFullWidth(windowWidth) * 0.75f else DEFAULT_MAX_IMAGE_WIDTH }
|
||||
@@ -118,12 +122,12 @@ private fun VideoView(uri: URI, file: CIFile, defaultPreview: ImageBitmap, defau
|
||||
player,
|
||||
width,
|
||||
onClick = onClick,
|
||||
onLongClick = { showMenu.value = true },
|
||||
onLongClick = onLongClick,
|
||||
stop
|
||||
)
|
||||
if (showPreview.value) {
|
||||
ImageView(preview, showMenu, onClick)
|
||||
PlayButton(brokenVideo, onLongClick = { showMenu.value = true }, play)
|
||||
VideoPreviewImageView(preview, onClick, onLongClick)
|
||||
PlayButton(brokenVideo, onLongClick = onLongClick, if (appPlatform.isAndroid) play else onClick)
|
||||
}
|
||||
DurationProgress(file, videoPlaying, duration, progress/*, soundEnabled*/)
|
||||
}
|
||||
@@ -201,7 +205,7 @@ private fun DurationProgress(file: CIFile, playing: MutableState<Boolean>, durat
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ImageView(preview: ImageBitmap, showMenu: MutableState<Boolean>, onClick: () -> Unit) {
|
||||
fun VideoPreviewImageView(preview: ImageBitmap, 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(
|
||||
@@ -210,10 +214,10 @@ private fun ImageView(preview: ImageBitmap, showMenu: MutableState<Boolean>, onC
|
||||
modifier = Modifier
|
||||
.width(width)
|
||||
.combinedClickable(
|
||||
onLongClick = { showMenu.value = true },
|
||||
onLongClick = onLongClick,
|
||||
onClick = onClick
|
||||
)
|
||||
.onRightClick { showMenu.value = true },
|
||||
.onRightClick(onLongClick),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -46,9 +46,11 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
|
||||
val scope = rememberCoroutineScope()
|
||||
val playersToRelease = rememberSaveable { mutableSetOf<URI>() }
|
||||
DisposableEffectOnGone(
|
||||
whenGone = { playersToRelease.forEach { VideoPlayer.release(it, true, true) } }
|
||||
whenGone = { playersToRelease.forEach { VideoPlayerHolder.release(it, true, true) } }
|
||||
)
|
||||
HorizontalPager(pageCount = remember { provider.totalMediaSize }.value, state = pagerState) { index ->
|
||||
|
||||
@Composable
|
||||
fun Content(index: Int) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
@@ -127,7 +129,7 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
|
||||
FullScreenImageView(modifier, data, imageBitmap)
|
||||
} else if (media is ProviderMedia.Video) {
|
||||
val preview = remember(media.uri.path) { base64ToBitmap(media.preview) }
|
||||
VideoView(modifier, media.uri, preview, index == settledCurrentPage)
|
||||
VideoView(modifier, media.uri, preview, index == settledCurrentPage, close)
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { playersToRelease.add(media.uri) }
|
||||
}
|
||||
@@ -135,14 +137,19 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
|
||||
}
|
||||
}
|
||||
}
|
||||
if (appPlatform.isAndroid) {
|
||||
HorizontalPager(pageCount = remember { provider.totalMediaSize }.value, state = pagerState) { index -> Content(index) }
|
||||
} else {
|
||||
Content(pagerState.currentPage)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
expect fun FullScreenImageView(modifier: Modifier, data: ByteArray, imageBitmap: ImageBitmap)
|
||||
|
||||
@Composable
|
||||
private fun VideoView(modifier: Modifier, uri: URI, defaultPreview: ImageBitmap, currentPage: Boolean) {
|
||||
val player = remember(uri) { VideoPlayer.getOrCreate(uri, true, defaultPreview, 0L, true) }
|
||||
private fun VideoView(modifier: Modifier, uri: URI, defaultPreview: ImageBitmap, currentPage: Boolean, close: () -> Unit) {
|
||||
val player = remember(uri) { VideoPlayerHolder.getOrCreate(uri, true, defaultPreview, 0L, true) }
|
||||
val isCurrentPage = rememberUpdatedState(currentPage)
|
||||
val play = {
|
||||
player.play(true)
|
||||
@@ -154,13 +161,16 @@ private fun VideoView(modifier: Modifier, uri: URI, defaultPreview: ImageBitmap,
|
||||
player.enableSound(true)
|
||||
snapshotFlow { isCurrentPage.value }
|
||||
.distinctUntilChanged()
|
||||
.collect { if (it) play() else stop() }
|
||||
.collect {
|
||||
// Do not autoplay on desktop because it needs workaround
|
||||
if (it && appPlatform.isAndroid) play() else if (!it) stop()
|
||||
}
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
FullScreenVideoView(player, modifier)
|
||||
FullScreenVideoView(player, modifier, close)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
expect fun FullScreenVideoView(player: VideoPlayer, modifier: Modifier)
|
||||
expect fun FullScreenVideoView(player: VideoPlayer, modifier: Modifier, close: () -> Unit)
|
||||
|
||||
@@ -66,6 +66,8 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
|
||||
if (chatModel.chatId.value != null) {
|
||||
ModalManager.end.closeModalsExceptFirst()
|
||||
}
|
||||
AudioPlayer.stop()
|
||||
VideoPlayerHolder.stopAll()
|
||||
}
|
||||
}
|
||||
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
|
||||
|
||||
Reference in New Issue
Block a user