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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 787 additions and 187 deletions

View File

@ -139,8 +139,6 @@ dependencies {
//implementation("androidx.compose.material:material-icons-extended:$compose_version") //implementation("androidx.compose.material:material-icons-extended:$compose_version")
//implementation("androidx.compose.ui:ui-util:$compose_version") //implementation("androidx.compose.ui:ui-util:$compose_version")
implementation("com.google.accompanist:accompanist-pager:0.25.1")
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.3") androidTestImplementation("androidx.test.ext:junit:1.1.3")
androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0") androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0")

View File

@ -73,7 +73,7 @@ class MainActivity: FragmentActivity() {
override fun onStop() { override fun onStop() {
super.onStop() super.onStop()
VideoPlayer.stopAll() VideoPlayerHolder.stopAll()
AppLock.appWasHidden() AppLock.appWasHidden()
} }

View File

@ -97,6 +97,7 @@ kotlin {
implementation("com.github.Dansoftowner:jSystemThemeDetector:3.6") implementation("com.github.Dansoftowner:jSystemThemeDetector:3.6")
implementation("com.sshtools:two-slices:0.9.0-SNAPSHOT") implementation("com.sshtools:two-slices:0.9.0-SNAPSHOT")
implementation("org.slf4j:slf4j-simple:2.0.7") implementation("org.slf4j:slf4j-simple:2.0.7")
implementation("uk.co.caprica:vlcj:4.7.0")
} }
} }
val desktopTest by getting val desktopTest by getting

View File

@ -27,7 +27,7 @@ actual class RecorderNative: RecorderInterface {
} }
override fun start(onProgressUpdate: (position: Int?, finished: Boolean) -> Unit): String { override fun start(onProgressUpdate: (position: Int?, finished: Boolean) -> Unit): String {
VideoPlayer.stopAll() VideoPlayerHolder.stopAll()
AudioPlayer.stop() AudioPlayer.stop()
val rec: MediaRecorder val rec: MediaRecorder
recorder = initRecorder().also { rec = it } recorder = initRecorder().also { rec = it }
@ -140,7 +140,7 @@ actual object AudioPlayer: AudioPlayerInterface {
return null return null
} }
VideoPlayer.stopAll() VideoPlayerHolder.stopAll()
RecorderInterface.stopRecording?.invoke() RecorderInterface.stopRecording?.invoke()
val current = currentlyPlaying.value val current = currentlyPlaying.value
if (current == null || current.first != fileSource.filePath) { if (current == null || current.first != fileSource.filePath) {

View File

@ -1,10 +1,13 @@
package chat.simplex.common.platform package chat.simplex.common.platform
import android.media.MediaMetadataRetriever
import android.media.session.PlaybackState import android.media.session.PlaybackState
import android.net.Uri import android.net.Uri
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import chat.simplex.common.helpers.toUri
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR import chat.simplex.res.MR
import com.google.android.exoplayer2.* import com.google.android.exoplayer2.*
@ -17,49 +20,15 @@ import kotlinx.coroutines.*
import java.io.File import java.io.File
import java.net.URI import java.net.URI
actual class VideoPlayer private constructor( actual class VideoPlayer actual constructor(
private val uri: URI, override val uri: URI,
private val gallery: Boolean, override val gallery: Boolean,
private val defaultPreview: ImageBitmap, private val defaultPreview: ImageBitmap,
defaultDuration: Long, defaultDuration: Long,
soundEnabled: Boolean soundEnabled: Boolean
): VideoPlayerInterface { ): VideoPlayerInterface {
actual companion object {
private val players: MutableMap<Pair<URI, Boolean>, VideoPlayer> = mutableMapOf()
private val previewsAndDurations: MutableMap<URI, VideoPlayerInterface.PreviewAndDuration> = mutableMapOf()
actual fun getOrCreate(
uri: URI,
gallery: Boolean,
defaultPreview: ImageBitmap,
defaultDuration: Long,
soundEnabled: Boolean
): VideoPlayer =
players.getOrPut(uri to gallery) { VideoPlayer(uri, gallery, defaultPreview, defaultDuration, soundEnabled) }
actual 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 }
}
actual fun release(uri: URI, gallery: Boolean, remove: Boolean) =
player(uri.path, gallery)?.release(remove).run { }
actual fun stopAll() {
players.values.forEach { it.stop() }
}
actual fun releaseAll() {
players.values.forEach { it.release(false) }
players.clear()
previewsAndDurations.clear()
}
}
private val currentVolume: Float private val currentVolume: Float
override val soundEnabled: MutableState<Boolean> = mutableStateOf(soundEnabled) override val soundEnabled: MutableState<Boolean> = mutableStateOf(soundEnabled)
override val brokenVideo: MutableState<Boolean> = mutableStateOf(false) override val brokenVideo: MutableState<Boolean> = mutableStateOf(false)
override val videoPlaying: MutableState<Boolean> = mutableStateOf(false) override val videoPlaying: MutableState<Boolean> = mutableStateOf(false)
@ -114,7 +83,7 @@ actual class VideoPlayer private constructor(
RecorderInterface.stopRecording?.invoke() RecorderInterface.stopRecording?.invoke()
} }
AudioPlayer.stop() AudioPlayer.stop()
stopAll() VideoPlayerHolder.stopAll()
if (listener.value == null) { if (listener.value == null) {
runCatching { runCatching {
val dataSourceFactory = DefaultDataSource.Factory(androidAppContext, DefaultHttpDataSource.Factory()) val dataSourceFactory = DefaultDataSource.Factory(androidAppContext, DefaultHttpDataSource.Factory())
@ -224,14 +193,14 @@ actual class VideoPlayer private constructor(
override fun release(remove: Boolean) { override fun release(remove: Boolean) {
player.release() player.release()
if (remove) { if (remove) {
players.remove(uri to gallery) VideoPlayerHolder.players.remove(uri to gallery)
} }
} }
private fun setPreviewAndDuration() { private fun setPreviewAndDuration() {
// It freezes main thread, doing it in IO thread // It freezes main thread, doing it in IO thread
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
val previewAndDuration = previewsAndDurations.getOrPut(uri) { getBitmapFromVideo(uri) } val previewAndDuration = VideoPlayerHolder.previewsAndDurations.getOrPut(uri) { getBitmapFromVideo(uri) }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
preview.value = previewAndDuration.preview ?: defaultPreview preview.value = previewAndDuration.preview ?: defaultPreview
duration.value = (previewAndDuration.duration ?: 0) duration.value = (previewAndDuration.duration ?: 0)

View File

@ -51,7 +51,7 @@ actual fun FullScreenImageView(modifier: Modifier, data: ByteArray, imageBitmap:
} }
@Composable @Composable
actual fun FullScreenVideoView(player: VideoPlayer, modifier: Modifier) { actual fun FullScreenVideoView(player: VideoPlayer, modifier: Modifier, close: () -> Unit) {
AndroidView( AndroidView(
factory = { ctx -> factory = { ctx ->
StyledPlayerView(ctx).apply { StyledPlayerView(ctx).apply {

View File

@ -7,13 +7,12 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.* import androidx.compose.ui.text.font.*
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import chat.simplex.common.model.* import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.* import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.call.* import chat.simplex.common.views.call.*
import chat.simplex.common.views.chat.ComposeState import chat.simplex.common.views.chat.ComposeState
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.OnboardingStage 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 chat.simplex.res.MR
import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.ImageResource
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
@ -2113,6 +2112,23 @@ data class CryptoFile(
val isAbsolutePath: Boolean val isAbsolutePath: Boolean
get() = File(filePath).isAbsolute 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 { companion object {
fun plain(f: String): CryptoFile = CryptoFile(f, null) fun plain(f: String): CryptoFile = CryptoFile(f, null)
} }

View File

@ -7,6 +7,8 @@ import java.net.URI
interface VideoPlayerInterface { interface VideoPlayerInterface {
data class PreviewAndDuration(val preview: ImageBitmap?, val duration: Long?, val timestamp: Long) data class PreviewAndDuration(val preview: ImageBitmap?, val duration: Long?, val timestamp: Long)
val uri: URI
val gallery: Boolean
val soundEnabled: MutableState<Boolean> val soundEnabled: MutableState<Boolean>
val brokenVideo: MutableState<Boolean> val brokenVideo: MutableState<Boolean>
val videoPlaying: MutableState<Boolean> val videoPlaying: MutableState<Boolean>
@ -20,18 +22,45 @@ interface VideoPlayerInterface {
fun release(remove: Boolean) fun release(remove: Boolean)
} }
expect class VideoPlayer: VideoPlayerInterface { expect class VideoPlayer(
companion object { 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( fun getOrCreate(
uri: URI, uri: URI,
gallery: Boolean, gallery: Boolean,
defaultPreview: ImageBitmap, defaultPreview: ImageBitmap,
defaultDuration: Long, defaultDuration: Long,
soundEnabled: Boolean soundEnabled: Boolean
): VideoPlayer ): VideoPlayer =
fun enableSound(enable: Boolean, fileName: String?, gallery: Boolean): Boolean players.getOrPut(uri to gallery) { VideoPlayer(uri, gallery, defaultPreview, defaultDuration, soundEnabled) }
fun release(uri: URI, gallery: Boolean, remove: Boolean)
fun stopAll() fun enableSound(enable: Boolean, fileName: String?, gallery: Boolean): Boolean =
fun releaseAll() 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( DisposableEffectOnGone(
whenGone = { whenGone = {
VideoPlayer.releaseAll() VideoPlayerHolder.releaseAll()
} }
) )
LazyColumn(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) { LazyColumn(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) {

View File

@ -50,7 +50,7 @@ fun CIVideoView(
}) })
} else { } else {
Box { Box {
ImageView(preview, showMenu, onClick = { VideoPreviewImageView(preview, onClick = {
if (file != null) { if (file != null) {
when (file.fileStatus) { when (file.fileStatus) {
CIFileStatus.RcvInvitation -> CIFileStatus.RcvInvitation ->
@ -75,6 +75,9 @@ fun CIVideoView(
else -> {} else -> {}
} }
} }
},
onLongClick = {
showMenu.value = true
}) })
if (file != null) { if (file != null) {
DurationProgress(file, remember { mutableStateOf(false) }, remember { mutableStateOf(duration * 1000L) }, remember { mutableStateOf(0L) }/*, soundEnabled*/) DurationProgress(file, remember { mutableStateOf(false) }, remember { mutableStateOf(duration * 1000L) }, remember { mutableStateOf(0L) }/*, soundEnabled*/)
@ -90,7 +93,7 @@ fun CIVideoView(
@Composable @Composable
private fun VideoView(uri: URI, file: CIFile, defaultPreview: ImageBitmap, defaultDuration: Long, showMenu: MutableState<Boolean>, onClick: () -> Unit) { 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 videoPlaying = remember(uri.path) { player.videoPlaying }
val progress = remember(uri.path) { player.progress } val progress = remember(uri.path) { player.progress }
val duration = remember(uri.path) { player.duration } val duration = remember(uri.path) { player.duration }
@ -111,6 +114,7 @@ private fun VideoView(uri: URI, file: CIFile, defaultPreview: ImageBitmap, defau
stop() stop()
} }
} }
val onLongClick = { showMenu.value = true }
Box { Box {
val windowWidth = LocalWindowWidth() val windowWidth = LocalWindowWidth()
val width = remember(preview) { if (preview.width * 0.97 <= preview.height) videoViewFullWidth(windowWidth) * 0.75f else DEFAULT_MAX_IMAGE_WIDTH } 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, player,
width, width,
onClick = onClick, onClick = onClick,
onLongClick = { showMenu.value = true }, onLongClick = onLongClick,
stop stop
) )
if (showPreview.value) { if (showPreview.value) {
ImageView(preview, showMenu, onClick) VideoPreviewImageView(preview, onClick, onLongClick)
PlayButton(brokenVideo, onLongClick = { showMenu.value = true }, play) PlayButton(brokenVideo, onLongClick = onLongClick, if (appPlatform.isAndroid) play else onClick)
} }
DurationProgress(file, videoPlaying, duration, progress/*, soundEnabled*/) DurationProgress(file, videoPlaying, duration, progress/*, soundEnabled*/)
} }
@ -201,7 +205,7 @@ private fun DurationProgress(file: CIFile, playing: MutableState<Boolean>, durat
} }
@Composable @Composable
private fun ImageView(preview: ImageBitmap, showMenu: MutableState<Boolean>, onClick: () -> Unit) { fun VideoPreviewImageView(preview: ImageBitmap, onClick: () -> Unit, onLongClick: () -> Unit) {
val windowWidth = LocalWindowWidth() val windowWidth = LocalWindowWidth()
val width = remember(preview) { if (preview.width * 0.97 <= preview.height) videoViewFullWidth(windowWidth) * 0.75f else DEFAULT_MAX_IMAGE_WIDTH } val width = remember(preview) { if (preview.width * 0.97 <= preview.height) videoViewFullWidth(windowWidth) * 0.75f else DEFAULT_MAX_IMAGE_WIDTH }
Image( Image(
@ -210,10 +214,10 @@ private fun ImageView(preview: ImageBitmap, showMenu: MutableState<Boolean>, onC
modifier = Modifier modifier = Modifier
.width(width) .width(width)
.combinedClickable( .combinedClickable(
onLongClick = { showMenu.value = true }, onLongClick = onLongClick,
onClick = onClick onClick = onClick
) )
.onRightClick { showMenu.value = true }, .onRightClick(onLongClick),
contentScale = ContentScale.FillWidth, contentScale = ContentScale.FillWidth,
) )
} }

View File

@ -46,9 +46,11 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val playersToRelease = rememberSaveable { mutableSetOf<URI>() } val playersToRelease = rememberSaveable { mutableSetOf<URI>() }
DisposableEffectOnGone( 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( Column(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
@ -127,7 +129,7 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
FullScreenImageView(modifier, data, imageBitmap) FullScreenImageView(modifier, data, imageBitmap)
} else if (media is ProviderMedia.Video) { } else if (media is ProviderMedia.Video) {
val preview = remember(media.uri.path) { base64ToBitmap(media.preview) } 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) { DisposableEffect(Unit) {
onDispose { playersToRelease.add(media.uri) } 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 @Composable
expect fun FullScreenImageView(modifier: Modifier, data: ByteArray, imageBitmap: ImageBitmap) expect fun FullScreenImageView(modifier: Modifier, data: ByteArray, imageBitmap: ImageBitmap)
@Composable @Composable
private fun VideoView(modifier: Modifier, uri: URI, defaultPreview: ImageBitmap, currentPage: Boolean) { private fun VideoView(modifier: Modifier, uri: URI, defaultPreview: ImageBitmap, currentPage: Boolean, close: () -> Unit) {
val player = remember(uri) { VideoPlayer.getOrCreate(uri, true, defaultPreview, 0L, true) } val player = remember(uri) { VideoPlayerHolder.getOrCreate(uri, true, defaultPreview, 0L, true) }
val isCurrentPage = rememberUpdatedState(currentPage) val isCurrentPage = rememberUpdatedState(currentPage)
val play = { val play = {
player.play(true) player.play(true)
@ -154,13 +161,16 @@ private fun VideoView(modifier: Modifier, uri: URI, defaultPreview: ImageBitmap,
player.enableSound(true) player.enableSound(true)
snapshotFlow { isCurrentPage.value } snapshotFlow { isCurrentPage.value }
.distinctUntilChanged() .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) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
FullScreenVideoView(player, modifier) FullScreenVideoView(player, modifier, close)
} }
} }
@Composable @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) { if (chatModel.chatId.value != null) {
ModalManager.end.closeModalsExceptFirst() ModalManager.end.closeModalsExceptFirst()
} }
AudioPlayer.stop()
VideoPlayerHolder.stopAll()
} }
} }
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp

View File

@ -29,6 +29,10 @@ fun initApp() {
//testCrypto() //testCrypto()
} }
fun discoverVlcLibs(path: String) {
uk.co.caprica.vlcj.binding.LibC.INSTANCE.setenv("VLC_PLUGIN_PATH", path, 1)
}
private fun applyAppLocale() { private fun applyAppLocale() {
val lang = ChatController.appPrefs.appLanguage.get() val lang = ChatController.appPrefs.appLanguage.get()
if (lang == null || lang == Locale.getDefault().language) return if (lang == null || lang == Locale.getDefault().language) return

View File

@ -21,6 +21,8 @@ actual val agentDatabaseFileName: String = "simplex_v1_agent.db"
actual val databaseExportDir: File = tmpDir actual val databaseExportDir: File = tmpDir
val vlcDir: File = File(System.getProperty("java.io.tmpdir") + File.separator + "simplex-vlc").also { it.deleteOnExit() }
actual fun desktopOpenDatabaseDir() { actual fun desktopOpenDatabaseDir() {
if (Desktop.isDesktopSupported()) { if (Desktop.isDesktopSupported()) {
try { try {

View File

@ -1,9 +1,17 @@
package chat.simplex.common.platform package chat.simplex.common.platform
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import chat.simplex.common.model.* import chat.simplex.common.model.*
import chat.simplex.common.views.usersettings.showInDevelopingAlert import chat.simplex.common.views.helpers.AlertManager
import kotlinx.coroutines.CoroutineScope import chat.simplex.common.views.helpers.generalGetString
import chat.simplex.res.MR
import kotlinx.coroutines.*
import uk.co.caprica.vlcj.player.base.MediaPlayer
import uk.co.caprica.vlcj.player.base.State
import uk.co.caprica.vlcj.player.component.AudioPlayerComponent
import java.io.File
import kotlin.math.max
actual class RecorderNative: RecorderInterface { actual class RecorderNative: RecorderInterface {
override fun start(onProgressUpdate: (position: Int?, finished: Boolean) -> Unit): String { override fun start(onProgressUpdate: (position: Int?, finished: Boolean) -> Unit): String {
@ -18,35 +26,186 @@ actual class RecorderNative: RecorderInterface {
} }
actual object AudioPlayer: AudioPlayerInterface { actual object AudioPlayer: AudioPlayerInterface {
override fun play(fileSource: CryptoFile, audioPlaying: MutableState<Boolean>, progress: MutableState<Int>, duration: MutableState<Int>, resetOnEnd: Boolean) { val player by lazy { AudioPlayerComponent().mediaPlayer() }
showInDevelopingAlert()
// Filepath: String, onProgressUpdate
private val currentlyPlaying: MutableState<Pair<CryptoFile, (position: Int?, state: TrackState) -> Unit>?> = mutableStateOf(null)
private var progressJob: Job? = null
enum class TrackState {
PLAYING, PAUSED, REPLACED
} }
override fun stop() { // Returns real duration of the track
/*LALAL*/ private fun start(fileSource: CryptoFile, seek: Int? = null, onProgressUpdate: (position: Int?, state: TrackState) -> Unit): Int? {
val absoluteFilePath = getAppFilePath(fileSource.filePath)
if (!File(absoluteFilePath).exists()) {
Log.e(TAG, "No such file: ${fileSource.filePath}")
return null
} }
override fun stop(item: ChatItem) { VideoPlayerHolder.stopAll()
/*LALAL*/ RecorderInterface.stopRecording?.invoke()
val current = currentlyPlaying.value
if (current == null || current.first != fileSource) {
stopListener()
player.stop()
runCatching {
if (fileSource.cryptoArgs != null) {
val tmpFile = fileSource.createTmpFileIfNeeded()
decryptCryptoFile(absoluteFilePath, fileSource.cryptoArgs, tmpFile.absolutePath)
player.media().prepare("file://${tmpFile.absolutePath}")
} else {
player.media().prepare("file://$absoluteFilePath")
} }
}.onFailure {
override fun stop(fileName: String?) { Log.e(TAG, it.stackTraceToString())
TODO("Not yet implemented") AlertManager.shared.showAlertMsg(generalGetString(MR.strings.unknown_error), it.message)
}
override fun pause(audioPlaying: MutableState<Boolean>, pro: MutableState<Int>) {
TODO("Not yet implemented")
}
override fun seekTo(ms: Int, pro: MutableState<Int>, filePath: String?) {
/*LALAL*/
}
override fun duration(unencryptedFilePath: String): Int? {
/*LALAL*/
return null return null
} }
} }
if (seek != null) player.seekTo(seek)
player.start()
currentlyPlaying.value = fileSource to onProgressUpdate
progressJob = CoroutineScope(Dispatchers.Default).launch {
onProgressUpdate(player.currentPosition, TrackState.PLAYING)
while(isActive && (player.isPlaying || player.status().state() == State.OPENING)) {
// Even when current position is equal to duration, the player has isPlaying == true for some time,
// so help to make the playback stopped in UI immediately
if (player.currentPosition == player.duration) {
onProgressUpdate(player.currentPosition, TrackState.PLAYING)
break
}
delay(50)
onProgressUpdate(player.currentPosition, TrackState.PLAYING)
}
onProgressUpdate(null, TrackState.PAUSED)
currentlyPlaying.value?.first?.deleteTmpFile()
}
return player.duration
}
private fun pause(): Int {
progressJob?.cancel()
progressJob = null
val position = player.currentPosition
player.pause()
return position
}
override fun stop() {
if (currentlyPlaying.value == null) return
player.stop()
stopListener()
}
override fun stop(item: ChatItem) = stop(item.file?.fileName)
// FileName or filePath are ok
override fun stop(fileName: String?) {
if (fileName != null && currentlyPlaying.value?.first?.filePath?.endsWith(fileName) == true) {
stop()
}
}
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 = null
}
/** Preventing race by calling a code AFTER coroutine ends, so [TrackState] will be:
* [TrackState.PLAYING] -> [TrackState.PAUSED] -> [TrackState.REPLACED] (in this order)
* */
if (progressJob != null) {
progressJob?.invokeOnCompletion(afterCoroutineCancel)
} else {
afterCoroutineCancel(null)
}
progressJob?.cancel()
progressJob = null
}
override fun play(
fileSource: CryptoFile,
audioPlaying: MutableState<Boolean>,
progress: MutableState<Int>,
duration: MutableState<Int>,
resetOnEnd: Boolean,
) {
if (progress.value == duration.value) {
progress.value = 0
}
val realDuration = start(fileSource, progress.value) { pro, state ->
if (pro != null) {
progress.value = pro
}
if (pro == null || pro == duration.value) {
audioPlaying.value = false
if (pro == duration.value) {
progress.value = if (resetOnEnd) 0 else duration.value
} else if (state == TrackState.REPLACED) {
progress.value = 0
}
}
}
audioPlaying.value = realDuration != null
// Update to real duration instead of what was received in ChatInfo
realDuration?.let { duration.value = it }
}
override fun pause(audioPlaying: MutableState<Boolean>, pro: MutableState<Int>) {
pro.value = pause()
audioPlaying.value = false
}
override fun seekTo(ms: Int, pro: MutableState<Int>, filePath: String?) {
pro.value = ms
if (currentlyPlaying.value?.first?.filePath == filePath) {
player.seekTo(ms)
}
}
override fun duration(unencryptedFilePath: String): Int? {
var res: Int? = null
try {
val helperPlayer = AudioPlayerComponent().mediaPlayer()
helperPlayer.media().startPaused("file://$unencryptedFilePath")
res = helperPlayer.duration
helperPlayer.stop()
helperPlayer.release()
} catch (e: Exception) {
Log.e(TAG, e.stackTraceToString())
}
return res
}
}
val MediaPlayer.isPlaying: Boolean
get() = status().isPlaying
fun MediaPlayer.seekTo(time: Int) {
controls().setTime(time.toLong())
}
fun MediaPlayer.start() {
controls().start()
}
fun MediaPlayer.pause() {
controls().pause()
}
fun MediaPlayer.stop() {
controls().stop()
}
private val MediaPlayer.currentPosition: Int
get() = max(0, status().time().toInt())
val MediaPlayer.duration: Int
get() = media().info().duration().toInt()
actual object SoundPlayer: SoundPlayerInterface { actual object SoundPlayer: SoundPlayerInterface {
override fun start(scope: CoroutineScope, sound: Boolean) { /*LALAL*/ } override fun start(scope: CoroutineScope, sound: Boolean) { /*LALAL*/ }

View File

@ -3,51 +3,213 @@ package chat.simplex.common.platform
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import chat.simplex.common.views.usersettings.showInDevelopingAlert import androidx.compose.ui.graphics.toComposeImageBitmap
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import kotlinx.coroutines.*
import uk.co.caprica.vlcj.player.base.MediaPlayer
import uk.co.caprica.vlcj.player.component.CallbackMediaPlayerComponent
import uk.co.caprica.vlcj.player.component.EmbeddedMediaPlayerComponent
import java.awt.Component
import java.io.File
import java.net.URI import java.net.URI
import kotlin.math.max
actual class VideoPlayer: VideoPlayerInterface { actual class VideoPlayer actual constructor(
actual companion object { override val uri: URI,
actual fun getOrCreate( override val gallery: Boolean,
uri: URI, private val defaultPreview: ImageBitmap,
gallery: Boolean,
defaultPreview: ImageBitmap,
defaultDuration: Long, defaultDuration: Long,
soundEnabled: Boolean soundEnabled: Boolean
): VideoPlayer = VideoPlayer().also { ): VideoPlayerInterface {
it.preview.value = defaultPreview
it.duration.value = defaultDuration
it.soundEnabled.value = soundEnabled
}
actual fun enableSound(enable: Boolean, fileName: String?, gallery: Boolean): Boolean { /*TODO*/ return false }
actual fun release(uri: URI, gallery: Boolean, remove: Boolean) { /*TODO*/ }
actual fun stopAll() { /*LALAL*/ }
actual fun releaseAll() { /*LALAL*/ }
}
override val soundEnabled: MutableState<Boolean> = mutableStateOf(false) override val soundEnabled: MutableState<Boolean> = mutableStateOf(false)
override val brokenVideo: MutableState<Boolean> = mutableStateOf(false) override val brokenVideo: MutableState<Boolean> = mutableStateOf(false)
override val videoPlaying: MutableState<Boolean> = mutableStateOf(false) override val videoPlaying: MutableState<Boolean> = mutableStateOf(false)
override val progress: MutableState<Long> = mutableStateOf(0L) override val progress: MutableState<Long> = mutableStateOf(0L)
override val duration: MutableState<Long> = mutableStateOf(0L) override val duration: MutableState<Long> = mutableStateOf(0L)
override val preview: MutableState<ImageBitmap> = mutableStateOf(ImageBitmap(0, 0)) override val preview: MutableState<ImageBitmap> = mutableStateOf(defaultPreview)
val mediaPlayerComponent = initializeMediaPlayerComponent()
val player by lazy { mediaPlayerComponent.mediaPlayer() }
init {
withBGApi {
setPreviewAndDuration()
}
}
private val currentVolume: Int by lazy { player.audio().volume() }
private var isReleased: Boolean = false
private val listener: MutableState<((position: Long?, state: TrackState) -> Unit)?> = mutableStateOf(null)
private var progressJob: Job? = null
enum class TrackState {
PLAYING, PAUSED, STOPPED
}
private fun start(seek: Long? = null, onProgressUpdate: (position: Long?, state: TrackState) -> Unit): Boolean {
val filepath = getAppFilePath(uri)
if (filepath == null || !File(filepath).exists()) {
Log.e(TAG, "No such file: $uri")
brokenVideo.value = true
return false
}
if (soundEnabled.value) {
RecorderInterface.stopRecording?.invoke()
}
AudioPlayer.stop()
VideoPlayerHolder.stopAll()
val playerFilePath = uri.toString().replaceFirst("file:", "file://")
if (listener.value == null) {
runCatching {
player.media().prepare(playerFilePath)
if (seek != null) {
player.seekTo(seek.toInt())
}
}.onFailure {
Log.e(TAG, it.stackTraceToString())
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.unknown_error), it.message)
brokenVideo.value = true
return false
}
}
player.start()
if (seek != null) player.seekTo(seek.toInt())
if (!player.isPlaying) {
// Can happen when video file is broken
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.unknown_error))
brokenVideo.value = true
return false
}
listener.value = onProgressUpdate
// Player can only be accessed in one specific thread
progressJob = CoroutineScope(Dispatchers.Main).launch {
onProgressUpdate(player.currentPosition.toLong(), TrackState.PLAYING)
while (isActive && !isReleased && player.isPlaying) {
// Even when current position is equal to duration, the player has isPlaying == true for some time,
// so help to make the playback stopped in UI immediately
if (player.currentPosition == player.duration) {
onProgressUpdate(player.currentPosition.toLong(), TrackState.PLAYING)
break
}
delay(50)
onProgressUpdate(player.currentPosition.toLong(), TrackState.PLAYING)
}
if (isActive && !isReleased) {
onProgressUpdate(player.currentPosition.toLong(), TrackState.PAUSED)
}
onProgressUpdate(null, TrackState.PAUSED)
}
return true
}
override fun stop() { override fun stop() {
/*TODO*/ if (isReleased || !videoPlaying.value) return
player.controls().stop()
stopListener()
}
private fun stopListener() {
val afterCoroutineCancel: CompletionHandler = {
// Notify prev video listener about stop
listener.value?.invoke(null, TrackState.STOPPED)
}
/** Preventing race by calling a code AFTER coroutine ends, so [TrackState] will be:
* [TrackState.PLAYING] -> [TrackState.PAUSED] -> [TrackState.STOPPED] (in this order)
* */
if (progressJob != null) {
progressJob?.invokeOnCompletion(afterCoroutineCancel)
} else {
afterCoroutineCancel(null)
}
progressJob?.cancel()
progressJob = null
} }
override fun play(resetOnEnd: Boolean) { override fun play(resetOnEnd: Boolean) {
if (appPlatform.isDesktop) { if (progress.value == duration.value) {
showInDevelopingAlert() progress.value = 0
}
videoPlaying.value = start(progress.value) { pro, _ ->
if (pro != null) {
progress.value = pro
}
if ((pro == null || pro == duration.value) && duration.value != 0L) {
videoPlaying.value = false
if (pro == duration.value) {
progress.value = if (resetOnEnd) 0 else duration.value
}/* else if (state == TrackState.STOPPED) {
progress.value = 0 //
}*/
}
} }
} }
override fun enableSound(enable: Boolean): Boolean { override fun enableSound(enable: Boolean): Boolean {
/*TODO*/ if (isReleased) return false
return false if (soundEnabled.value == enable) return false
soundEnabled.value = enable
player.audio().setVolume(if (enable) currentVolume else 0)
return true
} }
override fun release(remove: Boolean) { override fun release(remove: Boolean) { withApi {
/*TODO*/ if (isReleased) return@withApi
isReleased = true
// TODO
/** [player.release] freezes thread for some reason. It happens periodically. So doing this we don't see the freeze, but it's still there */
if (player.isPlaying) player.stop()
CoroutineScope(Dispatchers.IO).launch { player.release() }
if (remove) {
VideoPlayerHolder.players.remove(uri to gallery)
}
}}
private val MediaPlayer.currentPosition: Int
get() = if (isReleased) 0 else max(0, player.status().time().toInt())
private suspend fun setPreviewAndDuration() {
// It freezes main thread, doing it in IO thread
CoroutineScope(Dispatchers.IO).launch {
val previewAndDuration = VideoPlayerHolder.previewsAndDurations.getOrPut(uri) { getBitmapFromVideo() }
withContext(Dispatchers.Main) {
preview.value = previewAndDuration.preview ?: defaultPreview
duration.value = (previewAndDuration.duration ?: 0)
}
}
}
private suspend fun getBitmapFromVideo(): VideoPlayerInterface.PreviewAndDuration {
val player = CallbackMediaPlayerComponent().mediaPlayer()
val filepath = getAppFilePath(uri)
if (filepath == null || !File(filepath).exists()) {
return VideoPlayerInterface.PreviewAndDuration(preview = defaultPreview, timestamp = 0L, duration = 0L)
}
player.media().startPaused(filepath)
val start = System.currentTimeMillis()
while (player.snapshots()?.get() == null && start + 5000 > System.currentTimeMillis()) {
delay(10)
}
val preview = player.snapshots()?.get()?.toComposeImageBitmap()
val duration = player.duration.toLong()
CoroutineScope(Dispatchers.IO).launch { player.release() }
return VideoPlayerInterface.PreviewAndDuration(preview = preview, timestamp = 0L, duration = duration)
}
private fun initializeMediaPlayerComponent(): Component {
return if (desktopPlatform.isMac()) {
CallbackMediaPlayerComponent()
} else {
EmbeddedMediaPlayerComponent()
}
}
private fun Component.mediaPlayer() = when (this) {
is CallbackMediaPlayerComponent -> mediaPlayer()
is EmbeddedMediaPlayerComponent -> mediaPlayer()
else -> error("mediaPlayer() can only be called on vlcj player components")
} }
} }

View File

@ -6,9 +6,7 @@ import androidx.compose.ui.unit.Dp
import chat.simplex.common.platform.VideoPlayer import chat.simplex.common.platform.VideoPlayer
@Composable @Composable
actual fun PlayerView(player: VideoPlayer, width: Dp, onClick: () -> Unit, onLongClick: () -> Unit, stop: () -> Unit) { actual fun PlayerView(player: VideoPlayer, width: Dp, onClick: () -> Unit, onLongClick: () -> Unit, stop: () -> Unit) {}
/* LALAL */
}
@Composable @Composable
actual fun LocalWindowWidth(): Dp { actual fun LocalWindowWidth(): Dp {

View File

@ -1,14 +1,23 @@
package chat.simplex.common.views.chat.item package chat.simplex.common.views.chat.item
import androidx.compose.foundation.Image import androidx.compose.foundation.*
import androidx.compose.runtime.Composable import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.awt.SwingPanel
import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.*
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import chat.simplex.common.platform.VideoPlayer import androidx.compose.ui.unit.dp
import chat.simplex.common.platform.*
import chat.simplex.common.simplexWindowState
import chat.simplex.common.views.helpers.getBitmapFromByteArray import chat.simplex.common.views.helpers.getBitmapFromByteArray
import chat.simplex.res.MR import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.delay
import kotlin.math.max
@Composable @Composable
actual fun FullScreenImageView(modifier: Modifier, data: ByteArray, imageBitmap: ImageBitmap) { actual fun FullScreenImageView(modifier: Modifier, data: ByteArray, imageBitmap: ImageBitmap) {
@ -20,6 +29,43 @@ actual fun FullScreenImageView(modifier: Modifier, data: ByteArray, imageBitmap:
) )
} }
@Composable @Composable
actual fun FullScreenVideoView(player: VideoPlayer, modifier: Modifier) { actual fun FullScreenVideoView(player: VideoPlayer, modifier: Modifier, close: () -> Unit) {
// Workaround. Without changing size of the window the screen flashes a lot even if it's not being recomposed
LaunchedEffect(Unit) {
simplexWindowState.windowState.size = simplexWindowState.windowState.size.copy(width = simplexWindowState.windowState.size.width + 1.dp)
delay(50)
player.play(true)
simplexWindowState.windowState.size = simplexWindowState.windowState.size.copy(width = simplexWindowState.windowState.size.width - 1.dp)
}
Box {
Box(Modifier.fillMaxSize().padding(bottom = 50.dp)) {
val factory = remember { { player.mediaPlayerComponent } }
SwingPanel(
background = Color.Transparent,
modifier = Modifier,
factory = factory
)
}
Controls(player, close)
}
}
@Composable
private fun BoxScope.Controls(player: VideoPlayer, close: () -> Unit) {
val playing = remember(player) { player.videoPlaying }
val progress = remember(player) { player.progress }
val duration = remember(player) { player.duration }
Row(Modifier.fillMaxWidth().align(Alignment.BottomCenter).height(50.dp)) {
IconButton(onClick = { if (playing.value) player.player.pause() else player.play(true) },) {
Icon(painterResource(if (playing.value) MR.images.ic_pause_filled else MR.images.ic_play_arrow_filled), null, Modifier.size(30.dp), tint = MaterialTheme.colors.primary)
}
Slider(
value = progress.value.toFloat() / max(0.0001f, duration.value.toFloat()),
onValueChange = { player.player.seekTo((it * duration.value).toInt()) },
modifier = Modifier.fillMaxWidth().weight(1f)
)
IconButton(onClick = close,) {
Icon(painterResource(MR.images.ic_close), null, Modifier.size(30.dp), tint = MaterialTheme.colors.primary)
}
}
} }

View File

@ -133,7 +133,6 @@ actual suspend fun saveTempImageUncompressed(image: ImageBitmap, asPng: Boolean)
} }
actual fun getBitmapFromVideo(uri: URI, timestamp: Long?, random: Boolean): VideoPlayerInterface.PreviewAndDuration { actual fun getBitmapFromVideo(uri: URI, timestamp: Long?, random: Boolean): VideoPlayerInterface.PreviewAndDuration {
// LALAL
return VideoPlayerInterface.PreviewAndDuration(preview = null, timestamp = 0L, duration = 0L) return VideoPlayerInterface.PreviewAndDuration(preview = null, timestamp = 0L, duration = 0L)
} }

View File

@ -1,6 +1,5 @@
import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.kotlin.util.capitalizeDecapitalize.toLowerCaseAsciiOnly import org.jetbrains.kotlin.util.capitalizeDecapitalize.toLowerCaseAsciiOnly
import java.util.*
plugins { plugins {
kotlin("multiplatform") kotlin("multiplatform")
@ -22,6 +21,7 @@ kotlin {
dependencies { dependencies {
implementation(project(":common")) implementation(project(":common"))
implementation(compose.desktop.currentOs) implementation(compose.desktop.currentOs)
implementation("net.java.dev.jna:jna:5.13.0")
} }
} }
val jvmTest by getting val jvmTest by getting
@ -33,16 +33,23 @@ compose {
desktop { desktop {
application { application {
// For debugging via VisualVM // For debugging via VisualVM
/*jvmArgs += listOf( val debugJava = false
if (debugJava) {
jvmArgs += listOf(
"-Dcom.sun.management.jmxremote.port=8080", "-Dcom.sun.management.jmxremote.port=8080",
"-Dcom.sun.management.jmxremote.ssl=false", "-Dcom.sun.management.jmxremote.ssl=false",
"-Dcom.sun.management.jmxremote.authenticate=false" "-Dcom.sun.management.jmxremote.authenticate=false"
)*/ )
}
mainClass = "chat.simplex.desktop.MainKt" mainClass = "chat.simplex.desktop.MainKt"
nativeDistributions { nativeDistributions {
// For debugging via VisualVM // For debugging via VisualVM
//modules("jdk.zipfs", "jdk.management.agent") if (debugJava) {
modules("jdk.zipfs") modules("jdk.zipfs", "jdk.unsupported", "jdk.management.agent")
} else {
// 'jdk.unsupported' is for vlcj
modules("jdk.zipfs", "jdk.unsupported")
}
//includeAllModules = true //includeAllModules = true
outputBaseDir.set(project.file("../release")) outputBaseDir.set(project.file("../release"))
targetFormats( targetFormats(
@ -145,7 +152,7 @@ tasks.named("compileJava") {
afterEvaluate { afterEvaluate {
tasks.create("cmakeBuildAndCopy") { tasks.create("cmakeBuildAndCopy") {
dependsOn("cmakeBuild") dependsOn("cmakeBuild")
doLast { val copyDetails = mutableMapOf<String, ArrayList<FileCopyDetails>>()
copy { copy {
from("${project(":desktop").buildDir}/cmake/main/linux-amd64", "$cppPath/desktop/libs/linux-x86_64", "$cppPath/desktop/libs/linux-x86_64/deps") from("${project(":desktop").buildDir}/cmake/main/linux-amd64", "$cppPath/desktop/libs/linux-x86_64", "$cppPath/desktop/libs/linux-x86_64/deps")
into("src/jvmMain/resources/libs/linux-x86_64") into("src/jvmMain/resources/libs/linux-x86_64")
@ -156,6 +163,14 @@ afterEvaluate {
includeEmptyDirs = false includeEmptyDirs = false
duplicatesStrategy = DuplicatesStrategy.INCLUDE duplicatesStrategy = DuplicatesStrategy.INCLUDE
} }
copy {
val destinationDir = "src/jvmMain/resources/libs/linux-x86_64/vlc"
from("$cppPath/desktop/libs/linux-x86_64/deps/vlc")
into(destinationDir)
includeEmptyDirs = false
duplicatesStrategy = DuplicatesStrategy.INCLUDE
copyIfNeeded(destinationDir, copyDetails)
}
copy { copy {
from("${project(":desktop").buildDir}/cmake/main/linux-aarch64", "$cppPath/desktop/libs/linux-aarch64", "$cppPath/desktop/libs/linux-aarch64/deps") from("${project(":desktop").buildDir}/cmake/main/linux-aarch64", "$cppPath/desktop/libs/linux-aarch64", "$cppPath/desktop/libs/linux-aarch64/deps")
into("src/jvmMain/resources/libs/linux-aarch64") into("src/jvmMain/resources/libs/linux-aarch64")
@ -166,6 +181,14 @@ afterEvaluate {
includeEmptyDirs = false includeEmptyDirs = false
duplicatesStrategy = DuplicatesStrategy.INCLUDE duplicatesStrategy = DuplicatesStrategy.INCLUDE
} }
copy {
val destinationDir = "src/jvmMain/resources/libs/linux-aarch64/vlc"
from("$cppPath/desktop/libs/linux-aarch64/deps/vlc")
into(destinationDir)
includeEmptyDirs = false
duplicatesStrategy = DuplicatesStrategy.INCLUDE
copyIfNeeded(destinationDir, copyDetails)
}
copy { copy {
from("${project(":desktop").buildDir}/cmake/main/win-amd64", "$cppPath/desktop/libs/windows-x86_64", "$cppPath/desktop/libs/windows-x86_64/deps") from("${project(":desktop").buildDir}/cmake/main/win-amd64", "$cppPath/desktop/libs/windows-x86_64", "$cppPath/desktop/libs/windows-x86_64/deps")
into("src/jvmMain/resources/libs/windows-x86_64") into("src/jvmMain/resources/libs/windows-x86_64")
@ -176,6 +199,14 @@ afterEvaluate {
includeEmptyDirs = false includeEmptyDirs = false
duplicatesStrategy = DuplicatesStrategy.INCLUDE duplicatesStrategy = DuplicatesStrategy.INCLUDE
} }
copy {
val destinationDir = "src/jvmMain/resources/libs/windows-x86_64/vlc"
from("$cppPath/desktop/libs/windows-x86_64/deps/vlc")
into(destinationDir)
includeEmptyDirs = false
duplicatesStrategy = DuplicatesStrategy.INCLUDE
copyIfNeeded(destinationDir, copyDetails)
}
copy { copy {
from("${project(":desktop").buildDir}/cmake/main/mac-x86_64", "$cppPath/desktop/libs/mac-x86_64", "$cppPath/desktop/libs/mac-x86_64/deps") from("${project(":desktop").buildDir}/cmake/main/mac-x86_64", "$cppPath/desktop/libs/mac-x86_64", "$cppPath/desktop/libs/mac-x86_64/deps")
into("src/jvmMain/resources/libs/mac-x86_64") into("src/jvmMain/resources/libs/mac-x86_64")
@ -186,6 +217,14 @@ afterEvaluate {
includeEmptyDirs = false includeEmptyDirs = false
duplicatesStrategy = DuplicatesStrategy.INCLUDE duplicatesStrategy = DuplicatesStrategy.INCLUDE
} }
copy {
val destinationDir = "src/jvmMain/resources/libs/mac-x86_64/vlc"
from("$cppPath/desktop/libs/mac-x86_64/deps/vlc")
into(destinationDir)
includeEmptyDirs = false
duplicatesStrategy = DuplicatesStrategy.INCLUDE
copyIfNeeded(destinationDir, copyDetails)
}
copy { copy {
from("${project(":desktop").buildDir}/cmake/main/mac-aarch64", "$cppPath/desktop/libs/mac-aarch64", "$cppPath/desktop/libs/mac-aarch64/deps") from("${project(":desktop").buildDir}/cmake/main/mac-aarch64", "$cppPath/desktop/libs/mac-aarch64", "$cppPath/desktop/libs/mac-aarch64/deps")
into("src/jvmMain/resources/libs/mac-aarch64") into("src/jvmMain/resources/libs/mac-aarch64")
@ -196,6 +235,36 @@ afterEvaluate {
includeEmptyDirs = false includeEmptyDirs = false
duplicatesStrategy = DuplicatesStrategy.INCLUDE duplicatesStrategy = DuplicatesStrategy.INCLUDE
} }
copy {
val destinationDir = "src/jvmMain/resources/libs/mac-aarch64/vlc"
from("$cppPath/desktop/libs/mac-aarch64/deps/vlc")
into(destinationDir)
includeEmptyDirs = false
duplicatesStrategy = DuplicatesStrategy.INCLUDE
copyIfNeeded(destinationDir, copyDetails)
}
doLast {
copyDetails.forEach { (destinationDir, details) ->
details.forEach { detail ->
val target = File(projectDir.absolutePath + File.separator + destinationDir + File.separator + detail.path)
if (target.exists()) {
target.setLastModified(detail.lastModified)
} }
} }
} }
}
}
}
fun CopySpec.copyIfNeeded(destinationDir: String, into: MutableMap<String, ArrayList<FileCopyDetails>>) {
val details = arrayListOf<FileCopyDetails>()
eachFile {
val targetFile = File(destinationDir, path)
if (file.lastModified() == targetFile.lastModified() && file.length() == targetFile.length()) {
exclude()
} else {
details.add(this)
}
}
into[destinationDir] = details
}

View File

@ -5,6 +5,8 @@ import chat.simplex.common.showApp
import java.io.File import java.io.File
import java.nio.file.* import java.nio.file.*
import java.nio.file.attribute.BasicFileAttributes import java.nio.file.attribute.BasicFileAttributes
import java.nio.file.attribute.FileTime
import kotlin.io.path.setLastModifiedTime
fun main() { fun main() {
initHaskell() initHaskell()
@ -20,6 +22,15 @@ private fun initHaskell() {
val libsTmpDir = File(tmpDir.absolutePath + File.separator + "libs") val libsTmpDir = File(tmpDir.absolutePath + File.separator + "libs")
copyResources(desktopPlatform.libPath, libsTmpDir.toPath()) copyResources(desktopPlatform.libPath, libsTmpDir.toPath())
System.load(File(libsTmpDir, libApp).absolutePath) System.load(File(libsTmpDir, libApp).absolutePath)
vlcDir.deleteRecursively()
Files.move(File(libsTmpDir, "vlc").toPath(), vlcDir.toPath(), StandardCopyOption.REPLACE_EXISTING)
// No picture without preloading it, only sound. However, with libs from AppImage it works without preloading
//val libXcb = "libvlc_xcb_events.so.0.0.0"
//System.load(File(File(vlcDir, "vlc"), libXcb).absolutePath)
System.setProperty("jna.library.path", vlcDir.absolutePath)
//discoverVlcLibs(File(File(vlcDir, "vlc"), "plugins").absolutePath)
libsTmpDir.deleteRecursively() libsTmpDir.deleteRecursively()
initHS() initHS()
} }
@ -34,7 +45,12 @@ private fun copyResources(from: String, to: Path) {
return FileVisitResult.CONTINUE return FileVisitResult.CONTINUE
} }
override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
Files.copy(file, to.resolve(resPath.relativize(file).toString()), StandardCopyOption.REPLACE_EXISTING) val dest = to.resolve(resPath.relativize(file).toString())
Files.copy(file, dest, StandardCopyOption.REPLACE_EXISTING)
// Setting the same time on file as the time set in script that generates VLC libs
if (dest.toString().contains("." + desktopPlatform.libExtension)) {
dest.setLastModifiedTime(FileTime.fromMillis(0))
}
return FileVisitResult.CONTINUE return FileVisitResult.CONTINUE
} }
}) })

View File

@ -23,3 +23,4 @@ rm -rf apps/multiplatform/desktop/build/cmake
mkdir -p apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ mkdir -p apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/
cp -r $BUILD_DIR/build/deps apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ cp -r $BUILD_DIR/build/deps apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/
cp $BUILD_DIR/build/libHSsimplex-chat-*-inplace-ghc${GHC_VERSION}.so apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ cp $BUILD_DIR/build/libHSsimplex-chat-*-inplace-ghc${GHC_VERSION}.so apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/
scripts/desktop/prepare-vlc-linux.sh

View File

@ -95,3 +95,4 @@ rm -rf apps/multiplatform/desktop/build/cmake
mkdir -p apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ mkdir -p apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/
cp -r $BUILD_DIR/build/deps apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ cp -r $BUILD_DIR/build/deps apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/
cp $BUILD_DIR/build/libHSsimplex-chat-*-inplace-ghc*.$LIB_EXT apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ cp $BUILD_DIR/build/libHSsimplex-chat-*-inplace-ghc*.$LIB_EXT apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/
scripts/desktop/prepare-vlc-mac.sh

View File

@ -0,0 +1,74 @@
#!/bin/bash
set -e
function readlink() {
echo "$(cd "$(dirname "$1")"; pwd -P)"
}
root_dir="$(dirname "$(dirname "$(readlink "$0")")")"
vlc_dir=$root_dir/apps/multiplatform/common/src/commonMain/cpp/desktop/libs/linux-x86_64/deps/vlc
mkdir $vlc_dir || exit 0
cd /tmp
mkdir tmp 2>/dev/null || true
cd tmp
curl https://github.com/cmatomic/VLCplayer-AppImage/releases/download/3.0.11.1/VLC_media_player-3.0.11.1-x86_64.AppImage -L -o appimage
chmod +x appimage
./appimage --appimage-extract
cp -r squashfs-root/usr/lib/* $vlc_dir
cd ../
rm -rf tmp
exit 0
# This is currently unneeded
cd /tmp
(
mkdir tmp
cd tmp
curl http://archive.ubuntu.com/ubuntu/pool/universe/v/vlc/libvlc5_3.0.9.2-1_amd64.deb -o libvlc
ar p libvlc data.tar.xz > data.tar.xz
tar -xvf data.tar.xz
mv usr/lib/x86_64-linux-gnu/libvlc.so{.5,}
cp usr/lib/x86_64-linux-gnu/libvlc.so* $vlc_dir
cd ../
rm -rf tmp
)
(
mkdir tmp
cd tmp
curl http://archive.ubuntu.com/ubuntu/pool/universe/v/vlc/libvlccore9_3.0.9.2-1_amd64.deb -o libvlccore
ar p libvlccore data.tar.xz > data.tar.xz
tar -xvf data.tar.xz
cp usr/lib/x86_64-linux-gnu/libvlccore.so* $vlc_dir
cd ../
rm -rf tmp
)
(
mkdir tmp
cd tmp
curl http://mirrors.edge.kernel.org/ubuntu/pool/universe/v/vlc/vlc-plugin-base_3.0.9.2-1_amd64.deb -o plugins
ar p plugins data.tar.xz > data.tar.xz
tar -xvf data.tar.xz
find usr/lib/x86_64-linux-gnu/vlc/plugins/ -name "lib*.so*" -exec patchelf --set-rpath '$ORIGIN/../../' {} \;
cp -r usr/lib/x86_64-linux-gnu/vlc/{libvlc*,plugins} $vlc_dir
cd ../
rm -rf tmp
)
(
mkdir tmp
cd tmp
curl http://archive.ubuntu.com/ubuntu/pool/main/libi/libidn/libidn11_1.33-2.2ubuntu2_amd64.deb -o idn
ar p idn data.tar.xz > data.tar.xz
tar -xvf data.tar.xz
cp lib/x86_64-linux-gnu/lib* $vlc_dir
cd ../
rm -rf tmp
)
find $vlc_dir -maxdepth 1 -name "lib*.so*" -exec patchelf --set-rpath '$ORIGIN' {} \;

View File

@ -0,0 +1,40 @@
#!/bin/bash
set -e
ARCH="${1:-`uname -a | rev | cut -d' ' -f1 | rev`}"
if [ "$ARCH" == "arm64" ]; then
ARCH=aarch64
vlc_arch=arm64
else
vlc_arch=intel64
fi
vlc_version=3.0.19
function readlink() {
echo "$(cd "$(dirname "$1")"; pwd -P)"
}
root_dir="$(dirname "$(dirname "$(readlink "$0")")")"
vlc_dir=$root_dir/apps/multiplatform/common/src/commonMain/cpp/desktop/libs/mac-$ARCH/deps/vlc
#rm -rf $vlc_dir
mkdir -p $vlc_dir/vlc || exit 0
cd /tmp
mkdir tmp 2>/dev/null || true
cd tmp
curl https://github.com/simplex-chat/vlc/releases/download/v$vlc_version/vlc-macos-$ARCH.zip -L -o vlc
unzip -oqq vlc
install_name_tool -add_rpath "@loader_path/VLC.app/Contents/MacOS/lib" vlc-cache-gen
cd VLC.app/Contents/MacOS/lib
for lib in $(ls *.dylib); do install_name_tool -add_rpath "@loader_path" $lib 2> /dev/null || true; done
cd ../plugins
for lib in $(ls *.dylib); do
install_name_tool -add_rpath "@loader_path/../../" $lib 2> /dev/null || true
done
cd ..
../../../vlc-cache-gen plugins
cp lib/* $vlc_dir/
cp -r -p plugins/ $vlc_dir/vlc/plugins
cd ../../../../
rm -rf tmp