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,
fun getOrCreate( gallery: Boolean,
uri: URI, defaultPreview: ImageBitmap,
gallery: Boolean, defaultDuration: Long,
defaultPreview: ImageBitmap, soundEnabled: Boolean
defaultDuration: Long, ): VideoPlayerInterface
soundEnabled: Boolean
): VideoPlayer object VideoPlayerHolder {
fun enableSound(enable: Boolean, fileName: String?, gallery: Boolean): Boolean val players: MutableMap<Pair<URI, Boolean>, VideoPlayer> = mutableMapOf()
fun release(uri: URI, gallery: Boolean, remove: Boolean) val previewsAndDurations: MutableMap<URI, VideoPlayerInterface.PreviewAndDuration> = mutableMapOf()
fun stopAll()
fun releaseAll() 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( 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,7 +75,10 @@ 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,36 +26,187 @@ 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
}
// Returns real duration of the track
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
}
VideoPlayerHolder.stopAll()
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 {
Log.e(TAG, it.stackTraceToString())
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.unknown_error), it.message)
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() { override fun stop() {
/*LALAL*/ if (currentlyPlaying.value == null) return
player.stop()
stopListener()
} }
override fun stop(item: ChatItem) { override fun stop(item: ChatItem) = stop(item.file?.fileName)
/*LALAL*/
}
// FileName or filePath are ok
override fun stop(fileName: String?) { override fun stop(fileName: String?) {
TODO("Not yet implemented") 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>) { override fun pause(audioPlaying: MutableState<Boolean>, pro: MutableState<Int>) {
TODO("Not yet implemented") pro.value = pause()
audioPlaying.value = false
} }
override fun seekTo(ms: Int, pro: MutableState<Int>, filePath: String?) { override fun seekTo(ms: Int, pro: MutableState<Int>, filePath: String?) {
/*LALAL*/ pro.value = ms
if (currentlyPlaying.value?.first?.filePath == filePath) {
player.seekTo(ms)
}
} }
override fun duration(unencryptedFilePath: String): Int? { override fun duration(unencryptedFilePath: String): Int? {
/*LALAL*/ var res: Int? = null
return 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*/ }
override fun stop() { /*LALAL*/ } override fun stop() { /*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, defaultDuration: Long,
defaultPreview: ImageBitmap, soundEnabled: Boolean
defaultDuration: Long, ): VideoPlayerInterface {
soundEnabled: Boolean
): VideoPlayer = VideoPlayer().also {
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
"-Dcom.sun.management.jmxremote.port=8080", if (debugJava) {
"-Dcom.sun.management.jmxremote.ssl=false", jvmArgs += listOf(
"-Dcom.sun.management.jmxremote.authenticate=false" "-Dcom.sun.management.jmxremote.port=8080",
)*/ "-Dcom.sun.management.jmxremote.ssl=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,57 +152,119 @@ tasks.named("compileJava") {
afterEvaluate { afterEvaluate {
tasks.create("cmakeBuildAndCopy") { tasks.create("cmakeBuildAndCopy") {
dependsOn("cmakeBuild") dependsOn("cmakeBuild")
val copyDetails = mutableMapOf<String, ArrayList<FileCopyDetails>>()
copy {
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")
include("*.so*")
eachFile {
path = name
}
includeEmptyDirs = false
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 {
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")
include("*.so*")
eachFile {
path = name
}
includeEmptyDirs = false
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 {
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")
include("*.dll")
eachFile {
path = name
}
includeEmptyDirs = false
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 {
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")
include("*.dylib")
eachFile {
path = name
}
includeEmptyDirs = false
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 {
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")
include("*.dylib")
eachFile {
path = name
}
includeEmptyDirs = false
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 { doLast {
copy { copyDetails.forEach { (destinationDir, details) ->
from("${project(":desktop").buildDir}/cmake/main/linux-amd64", "$cppPath/desktop/libs/linux-x86_64", "$cppPath/desktop/libs/linux-x86_64/deps") details.forEach { detail ->
into("src/jvmMain/resources/libs/linux-x86_64") val target = File(projectDir.absolutePath + File.separator + destinationDir + File.separator + detail.path)
include("*.so*") if (target.exists()) {
eachFile { target.setLastModified(detail.lastModified)
path = name }
} }
includeEmptyDirs = false
duplicatesStrategy = DuplicatesStrategy.INCLUDE
}
copy {
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")
include("*.so*")
eachFile {
path = name
}
includeEmptyDirs = false
duplicatesStrategy = DuplicatesStrategy.INCLUDE
}
copy {
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")
include("*.dll")
eachFile {
path = name
}
includeEmptyDirs = false
duplicatesStrategy = DuplicatesStrategy.INCLUDE
}
copy {
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")
include("*.dylib")
eachFile {
path = name
}
includeEmptyDirs = false
duplicatesStrategy = DuplicatesStrategy.INCLUDE
}
copy {
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")
include("*.dylib")
eachFile {
path = name
}
includeEmptyDirs = false
duplicatesStrategy = DuplicatesStrategy.INCLUDE
} }
} }
} }
} }
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