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:
Stanislav Dmitrenko
2023-09-22 01:03:47 +08:00
committed by GitHub
parent 6de0ed4766
commit 2d7655281f
25 changed files with 787 additions and 187 deletions

View File

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

View File

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

View File

@@ -737,7 +737,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
}
DisposableEffectOnGone(
whenGone = {
VideoPlayer.releaseAll()
VideoPlayerHolder.releaseAll()
}
)
LazyColumn(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) {

View File

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

View File

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

View File

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