diff --git a/apps/multiplatform/android/build.gradle.kts b/apps/multiplatform/android/build.gradle.kts index bd45ee125..67a8fea87 100644 --- a/apps/multiplatform/android/build.gradle.kts +++ b/apps/multiplatform/android/build.gradle.kts @@ -139,8 +139,6 @@ dependencies { //implementation("androidx.compose.material:material-icons-extended:$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") androidTestImplementation("androidx.test.ext:junit:1.1.3") androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0") diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt index 06def4ce1..512e9efc1 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt @@ -73,7 +73,7 @@ class MainActivity: FragmentActivity() { override fun onStop() { super.onStop() - VideoPlayer.stopAll() + VideoPlayerHolder.stopAll() AppLock.appWasHidden() } diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index 5b9560b07..6a5fd1d0f 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -97,6 +97,7 @@ kotlin { implementation("com.github.Dansoftowner:jSystemThemeDetector:3.6") implementation("com.sshtools:two-slices:0.9.0-SNAPSHOT") implementation("org.slf4j:slf4j-simple:2.0.7") + implementation("uk.co.caprica:vlcj:4.7.0") } } val desktopTest by getting diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt index 5996193ab..8df99d15f 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt @@ -27,7 +27,7 @@ actual class RecorderNative: RecorderInterface { } override fun start(onProgressUpdate: (position: Int?, finished: Boolean) -> Unit): String { - VideoPlayer.stopAll() + VideoPlayerHolder.stopAll() AudioPlayer.stop() val rec: MediaRecorder recorder = initRecorder().also { rec = it } @@ -140,7 +140,7 @@ actual object AudioPlayer: AudioPlayerInterface { return null } - VideoPlayer.stopAll() + VideoPlayerHolder.stopAll() RecorderInterface.stopRecording?.invoke() val current = currentlyPlaying.value if (current == null || current.first != fileSource.filePath) { diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/VideoPlayer.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/VideoPlayer.android.kt index 984f83d45..9d5eadad7 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/VideoPlayer.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/VideoPlayer.android.kt @@ -1,10 +1,13 @@ package chat.simplex.common.platform +import android.media.MediaMetadataRetriever import android.media.session.PlaybackState import android.net.Uri import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf 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.res.MR import com.google.android.exoplayer2.* @@ -17,49 +20,15 @@ import kotlinx.coroutines.* import java.io.File import java.net.URI -actual class VideoPlayer private constructor( - private val uri: URI, - private val gallery: Boolean, +actual class VideoPlayer actual constructor( + override val uri: URI, + override val gallery: Boolean, private val defaultPreview: ImageBitmap, defaultDuration: Long, soundEnabled: Boolean ): VideoPlayerInterface { - actual companion object { - private val players: MutableMap, VideoPlayer> = mutableMapOf() - private val previewsAndDurations: MutableMap = 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 + override val soundEnabled: MutableState = mutableStateOf(soundEnabled) override val brokenVideo: MutableState = mutableStateOf(false) override val videoPlaying: MutableState = mutableStateOf(false) @@ -114,7 +83,7 @@ actual class VideoPlayer private constructor( RecorderInterface.stopRecording?.invoke() } AudioPlayer.stop() - stopAll() + VideoPlayerHolder.stopAll() if (listener.value == null) { runCatching { val dataSourceFactory = DefaultDataSource.Factory(androidAppContext, DefaultHttpDataSource.Factory()) @@ -224,14 +193,14 @@ actual class VideoPlayer private constructor( override fun release(remove: Boolean) { player.release() if (remove) { - players.remove(uri to gallery) + VideoPlayerHolder.players.remove(uri to gallery) } } private fun setPreviewAndDuration() { // It freezes main thread, doing it in IO thread CoroutineScope(Dispatchers.IO).launch { - val previewAndDuration = previewsAndDurations.getOrPut(uri) { getBitmapFromVideo(uri) } + val previewAndDuration = VideoPlayerHolder.previewsAndDurations.getOrPut(uri) { getBitmapFromVideo(uri) } withContext(Dispatchers.Main) { preview.value = previewAndDuration.preview ?: defaultPreview duration.value = (previewAndDuration.duration ?: 0) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.android.kt index ade538a04..d4efdc3e5 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.android.kt @@ -51,7 +51,7 @@ actual fun FullScreenImageView(modifier: Modifier, data: ByteArray, imageBitmap: } @Composable -actual fun FullScreenVideoView(player: VideoPlayer, modifier: Modifier) { +actual fun FullScreenVideoView(player: VideoPlayer, modifier: Modifier, close: () -> Unit) { AndroidView( factory = { ctx -> StyledPlayerView(ctx).apply { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 33b80322a..17c62c45e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -7,13 +7,12 @@ import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.* import androidx.compose.ui.text.style.TextDecoration import chat.simplex.common.model.* +import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* import chat.simplex.common.views.chat.ComposeState import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.OnboardingStage -import chat.simplex.common.platform.AudioPlayer -import chat.simplex.common.platform.chatController import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource @@ -2113,6 +2112,23 @@ data class CryptoFile( val isAbsolutePath: Boolean get() = File(filePath).isAbsolute + @Transient + private var tmpFile: File? = null + + fun createTmpFileIfNeeded(): File { + if (tmpFile == null) { + val tmpFile = File(tmpDir, UUID.randomUUID().toString()) + tmpFile.deleteOnExit() + ChatModel.filesToDelete.add(tmpFile) + this.tmpFile = tmpFile + } + return tmpFile!! + } + + fun deleteTmpFile() { + tmpFile?.delete() + } + companion object { fun plain(f: String): CryptoFile = CryptoFile(f, null) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/VideoPlayer.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/VideoPlayer.kt index bde9d8a49..5c3b50bbd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/VideoPlayer.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/VideoPlayer.kt @@ -7,6 +7,8 @@ import java.net.URI interface VideoPlayerInterface { data class PreviewAndDuration(val preview: ImageBitmap?, val duration: Long?, val timestamp: Long) + val uri: URI + val gallery: Boolean val soundEnabled: MutableState val brokenVideo: MutableState val videoPlaying: MutableState @@ -20,18 +22,45 @@ interface VideoPlayerInterface { fun release(remove: Boolean) } -expect class VideoPlayer: VideoPlayerInterface { - companion object { - fun getOrCreate( - uri: URI, - gallery: Boolean, - defaultPreview: ImageBitmap, - defaultDuration: Long, - soundEnabled: Boolean - ): VideoPlayer - fun enableSound(enable: Boolean, fileName: String?, gallery: Boolean): Boolean - fun release(uri: URI, gallery: Boolean, remove: Boolean) - fun stopAll() - fun releaseAll() +expect class VideoPlayer( + uri: URI, + gallery: Boolean, + defaultPreview: ImageBitmap, + defaultDuration: Long, + soundEnabled: Boolean +): VideoPlayerInterface + +object VideoPlayerHolder { + val players: MutableMap, VideoPlayer> = mutableMapOf() + val previewsAndDurations: MutableMap = mutableMapOf() + + fun getOrCreate( + uri: URI, + gallery: Boolean, + defaultPreview: ImageBitmap, + defaultDuration: Long, + soundEnabled: Boolean + ): VideoPlayer = + players.getOrPut(uri to gallery) { VideoPlayer(uri, gallery, defaultPreview, defaultDuration, soundEnabled) } + + fun enableSound(enable: Boolean, fileName: String?, gallery: Boolean): Boolean = + player(fileName, gallery)?.enableSound(enable) == true + + private fun player(fileName: String?, gallery: Boolean): VideoPlayer? { + fileName ?: return null + return players.values.firstOrNull { player -> player.uri.path?.endsWith(fileName) == true && player.gallery == gallery } + } + + fun release(uri: URI, gallery: Boolean, remove: Boolean) = + player(uri.path, gallery)?.release(remove).run { } + + fun stopAll() { + players.values.forEach { it.stop() } + } + + fun releaseAll() { + players.values.forEach { it.release(false) } + players.clear() + previewsAndDurations.clear() } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 31f6fee76..3a5e27ed2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -737,7 +737,7 @@ fun BoxWithConstraintsScope.ChatItemsList( } DisposableEffectOnGone( whenGone = { - VideoPlayer.releaseAll() + VideoPlayerHolder.releaseAll() } ) LazyColumn(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt index aad1e8a8f..78bdf53d1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVIdeoView.kt @@ -50,7 +50,7 @@ fun CIVideoView( }) } else { Box { - ImageView(preview, showMenu, onClick = { + VideoPreviewImageView(preview, onClick = { if (file != null) { when (file.fileStatus) { CIFileStatus.RcvInvitation -> @@ -75,7 +75,10 @@ fun CIVideoView( else -> {} } } - }) + }, + onLongClick = { + showMenu.value = true + }) if (file != null) { DurationProgress(file, remember { mutableStateOf(false) }, remember { mutableStateOf(duration * 1000L) }, remember { mutableStateOf(0L) }/*, soundEnabled*/) } @@ -90,7 +93,7 @@ fun CIVideoView( @Composable private fun VideoView(uri: URI, file: CIFile, defaultPreview: ImageBitmap, defaultDuration: Long, showMenu: MutableState, onClick: () -> Unit) { - val player = remember(uri) { VideoPlayer.getOrCreate(uri, false, defaultPreview, defaultDuration, true) } + val player = remember(uri) { VideoPlayerHolder.getOrCreate(uri, false, defaultPreview, defaultDuration, true) } val videoPlaying = remember(uri.path) { player.videoPlaying } val progress = remember(uri.path) { player.progress } val duration = remember(uri.path) { player.duration } @@ -111,6 +114,7 @@ private fun VideoView(uri: URI, file: CIFile, defaultPreview: ImageBitmap, defau stop() } } + val onLongClick = { showMenu.value = true } Box { val windowWidth = LocalWindowWidth() val width = remember(preview) { if (preview.width * 0.97 <= preview.height) videoViewFullWidth(windowWidth) * 0.75f else DEFAULT_MAX_IMAGE_WIDTH } @@ -118,12 +122,12 @@ private fun VideoView(uri: URI, file: CIFile, defaultPreview: ImageBitmap, defau player, width, onClick = onClick, - onLongClick = { showMenu.value = true }, + onLongClick = onLongClick, stop ) if (showPreview.value) { - ImageView(preview, showMenu, onClick) - PlayButton(brokenVideo, onLongClick = { showMenu.value = true }, play) + VideoPreviewImageView(preview, onClick, onLongClick) + PlayButton(brokenVideo, onLongClick = onLongClick, if (appPlatform.isAndroid) play else onClick) } DurationProgress(file, videoPlaying, duration, progress/*, soundEnabled*/) } @@ -201,7 +205,7 @@ private fun DurationProgress(file: CIFile, playing: MutableState, durat } @Composable -private fun ImageView(preview: ImageBitmap, showMenu: MutableState, onClick: () -> Unit) { +fun VideoPreviewImageView(preview: ImageBitmap, onClick: () -> Unit, onLongClick: () -> Unit) { val windowWidth = LocalWindowWidth() val width = remember(preview) { if (preview.width * 0.97 <= preview.height) videoViewFullWidth(windowWidth) * 0.75f else DEFAULT_MAX_IMAGE_WIDTH } Image( @@ -210,10 +214,10 @@ private fun ImageView(preview: ImageBitmap, showMenu: MutableState, onC modifier = Modifier .width(width) .combinedClickable( - onLongClick = { showMenu.value = true }, + onLongClick = onLongClick, onClick = onClick ) - .onRightClick { showMenu.value = true }, + .onRightClick(onLongClick), contentScale = ContentScale.FillWidth, ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt index 9664cabc4..05d11208f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt @@ -46,9 +46,11 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () -> val scope = rememberCoroutineScope() val playersToRelease = rememberSaveable { mutableSetOf() } DisposableEffectOnGone( - whenGone = { playersToRelease.forEach { VideoPlayer.release(it, true, true) } } + whenGone = { playersToRelease.forEach { VideoPlayerHolder.release(it, true, true) } } ) - HorizontalPager(pageCount = remember { provider.totalMediaSize }.value, state = pagerState) { index -> + + @Composable + fun Content(index: Int) { Column( Modifier .fillMaxSize() @@ -127,7 +129,7 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () -> FullScreenImageView(modifier, data, imageBitmap) } else if (media is ProviderMedia.Video) { val preview = remember(media.uri.path) { base64ToBitmap(media.preview) } - VideoView(modifier, media.uri, preview, index == settledCurrentPage) + VideoView(modifier, media.uri, preview, index == settledCurrentPage, close) DisposableEffect(Unit) { onDispose { playersToRelease.add(media.uri) } } @@ -135,14 +137,19 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () -> } } } + if (appPlatform.isAndroid) { + HorizontalPager(pageCount = remember { provider.totalMediaSize }.value, state = pagerState) { index -> Content(index) } + } else { + Content(pagerState.currentPage) + } } @Composable expect fun FullScreenImageView(modifier: Modifier, data: ByteArray, imageBitmap: ImageBitmap) @Composable -private fun VideoView(modifier: Modifier, uri: URI, defaultPreview: ImageBitmap, currentPage: Boolean) { - val player = remember(uri) { VideoPlayer.getOrCreate(uri, true, defaultPreview, 0L, true) } +private fun VideoView(modifier: Modifier, uri: URI, defaultPreview: ImageBitmap, currentPage: Boolean, close: () -> Unit) { + val player = remember(uri) { VideoPlayerHolder.getOrCreate(uri, true, defaultPreview, 0L, true) } val isCurrentPage = rememberUpdatedState(currentPage) val play = { player.play(true) @@ -154,13 +161,16 @@ private fun VideoView(modifier: Modifier, uri: URI, defaultPreview: ImageBitmap, player.enableSound(true) snapshotFlow { isCurrentPage.value } .distinctUntilChanged() - .collect { if (it) play() else stop() } + .collect { + // Do not autoplay on desktop because it needs workaround + if (it && appPlatform.isAndroid) play() else if (!it) stop() + } } Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - FullScreenVideoView(player, modifier) + FullScreenVideoView(player, modifier, close) } } @Composable -expect fun FullScreenVideoView(player: VideoPlayer, modifier: Modifier) +expect fun FullScreenVideoView(player: VideoPlayer, modifier: Modifier, close: () -> Unit) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index d8a14cb51..6d7450a21 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -66,6 +66,8 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf if (chatModel.chatId.value != null) { ModalManager.end.closeModalsExceptFirst() } + AudioPlayer.stop() + VideoPlayerHolder.stopAll() } } val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt index 612217925..7193fbe2b 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt @@ -29,6 +29,10 @@ fun initApp() { //testCrypto() } +fun discoverVlcLibs(path: String) { + uk.co.caprica.vlcj.binding.LibC.INSTANCE.setenv("VLC_PLUGIN_PATH", path, 1) +} + private fun applyAppLocale() { val lang = ChatController.appPrefs.appLanguage.get() if (lang == null || lang == Locale.getDefault().language) return diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt index 9042a6283..46124a44f 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt @@ -21,6 +21,8 @@ actual val agentDatabaseFileName: String = "simplex_v1_agent.db" actual val databaseExportDir: File = tmpDir +val vlcDir: File = File(System.getProperty("java.io.tmpdir") + File.separator + "simplex-vlc").also { it.deleteOnExit() } + actual fun desktopOpenDatabaseDir() { if (Desktop.isDesktopSupported()) { try { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt index 8e6a7d7ef..ed8efcd57 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt @@ -1,9 +1,17 @@ package chat.simplex.common.platform import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf import chat.simplex.common.model.* -import chat.simplex.common.views.usersettings.showInDevelopingAlert -import kotlinx.coroutines.CoroutineScope +import chat.simplex.common.views.helpers.AlertManager +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 { override fun start(onProgressUpdate: (position: Int?, finished: Boolean) -> Unit): String { @@ -18,36 +26,187 @@ actual class RecorderNative: RecorderInterface { } actual object AudioPlayer: AudioPlayerInterface { - override fun play(fileSource: CryptoFile, audioPlaying: MutableState, progress: MutableState, duration: MutableState, resetOnEnd: Boolean) { - showInDevelopingAlert() + val player by lazy { AudioPlayerComponent().mediaPlayer() } + + // Filepath: String, onProgressUpdate + private val currentlyPlaying: MutableState 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() { - /*LALAL*/ + if (currentlyPlaying.value == null) return + player.stop() + stopListener() } - override fun stop(item: ChatItem) { - /*LALAL*/ - } + override fun stop(item: ChatItem) = stop(item.file?.fileName) + // FileName or filePath are ok 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, + progress: MutableState, + duration: MutableState, + 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, pro: MutableState) { - TODO("Not yet implemented") + pro.value = pause() + audioPlaying.value = false } override fun seekTo(ms: Int, pro: MutableState, filePath: String?) { - /*LALAL*/ + pro.value = ms + if (currentlyPlaying.value?.first?.filePath == filePath) { + player.seekTo(ms) + } } override fun duration(unencryptedFilePath: String): Int? { - /*LALAL*/ - return null + 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 { override fun start(scope: CoroutineScope, sound: Boolean) { /*LALAL*/ } override fun stop() { /*LALAL*/ } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt index 90f6a593f..1d98c6497 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt @@ -3,51 +3,213 @@ package chat.simplex.common.platform import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf 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 kotlin.math.max -actual class VideoPlayer: VideoPlayerInterface { - actual companion object { - actual fun getOrCreate( - uri: URI, - gallery: Boolean, - defaultPreview: ImageBitmap, - defaultDuration: Long, - 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*/ } - } - +actual class VideoPlayer actual constructor( + override val uri: URI, + override val gallery: Boolean, + private val defaultPreview: ImageBitmap, + defaultDuration: Long, + soundEnabled: Boolean +): VideoPlayerInterface { override val soundEnabled: MutableState = mutableStateOf(false) override val brokenVideo: MutableState = mutableStateOf(false) override val videoPlaying: MutableState = mutableStateOf(false) override val progress: MutableState = mutableStateOf(0L) override val duration: MutableState = mutableStateOf(0L) - override val preview: MutableState = mutableStateOf(ImageBitmap(0, 0)) + override val preview: MutableState = 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() { - /*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) { - if (appPlatform.isDesktop) { - showInDevelopingAlert() + if (progress.value == duration.value) { + 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 { - /*TODO*/ - return false + if (isReleased) 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) { - /*TODO*/ + override fun release(remove: Boolean) { withApi { + 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") } } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.desktop.kt index aac995e48..c85057b47 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.desktop.kt @@ -6,9 +6,7 @@ import androidx.compose.ui.unit.Dp import chat.simplex.common.platform.VideoPlayer @Composable -actual fun PlayerView(player: VideoPlayer, width: Dp, onClick: () -> Unit, onLongClick: () -> Unit, stop: () -> Unit) { - /* LALAL */ -} +actual fun PlayerView(player: VideoPlayer, width: Dp, onClick: () -> Unit, onLongClick: () -> Unit, stop: () -> Unit) {} @Composable actual fun LocalWindowWidth(): Dp { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt index a73c2784e..9aafc83d2 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt @@ -1,14 +1,23 @@ package chat.simplex.common.views.chat.item -import androidx.compose.foundation.Image -import androidx.compose.runtime.Composable +import androidx.compose.foundation.* +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.awt.SwingPanel import androidx.compose.ui.graphics.* 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.res.MR +import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.delay +import kotlin.math.max @Composable actual fun FullScreenImageView(modifier: Modifier, data: ByteArray, imageBitmap: ImageBitmap) { @@ -20,6 +29,43 @@ actual fun FullScreenImageView(modifier: Modifier, data: ByteArray, imageBitmap: ) } @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) + } + } } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt index 4fa768a5d..cc84e9ac0 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt @@ -133,7 +133,6 @@ actual suspend fun saveTempImageUncompressed(image: ImageBitmap, asPng: Boolean) } actual fun getBitmapFromVideo(uri: URI, timestamp: Long?, random: Boolean): VideoPlayerInterface.PreviewAndDuration { - // LALAL return VideoPlayerInterface.PreviewAndDuration(preview = null, timestamp = 0L, duration = 0L) } diff --git a/apps/multiplatform/desktop/build.gradle.kts b/apps/multiplatform/desktop/build.gradle.kts index dc4aa89fb..39d08e046 100644 --- a/apps/multiplatform/desktop/build.gradle.kts +++ b/apps/multiplatform/desktop/build.gradle.kts @@ -1,6 +1,5 @@ import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.jetbrains.kotlin.util.capitalizeDecapitalize.toLowerCaseAsciiOnly -import java.util.* plugins { kotlin("multiplatform") @@ -22,6 +21,7 @@ kotlin { dependencies { implementation(project(":common")) implementation(compose.desktop.currentOs) + implementation("net.java.dev.jna:jna:5.13.0") } } val jvmTest by getting @@ -33,16 +33,23 @@ compose { desktop { application { // For debugging via VisualVM - /*jvmArgs += listOf( - "-Dcom.sun.management.jmxremote.port=8080", - "-Dcom.sun.management.jmxremote.ssl=false", - "-Dcom.sun.management.jmxremote.authenticate=false" - )*/ + val debugJava = false + if (debugJava) { + jvmArgs += listOf( + "-Dcom.sun.management.jmxremote.port=8080", + "-Dcom.sun.management.jmxremote.ssl=false", + "-Dcom.sun.management.jmxremote.authenticate=false" + ) + } mainClass = "chat.simplex.desktop.MainKt" nativeDistributions { // For debugging via VisualVM - //modules("jdk.zipfs", "jdk.management.agent") - modules("jdk.zipfs") + if (debugJava) { + modules("jdk.zipfs", "jdk.unsupported", "jdk.management.agent") + } else { + // 'jdk.unsupported' is for vlcj + modules("jdk.zipfs", "jdk.unsupported") + } //includeAllModules = true outputBaseDir.set(project.file("../release")) targetFormats( @@ -145,57 +152,119 @@ tasks.named("compileJava") { afterEvaluate { tasks.create("cmakeBuildAndCopy") { dependsOn("cmakeBuild") + val copyDetails = mutableMapOf>() + 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 { - 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 + 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) + } } - 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>) { + val details = arrayListOf() + eachFile { + val targetFile = File(destinationDir, path) + if (file.lastModified() == targetFile.lastModified() && file.length() == targetFile.length()) { + exclude() + } else { + details.add(this) + } + } + into[destinationDir] = details +} diff --git a/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt b/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt index 4d97bc49b..72c41a665 100644 --- a/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt +++ b/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt @@ -5,6 +5,8 @@ import chat.simplex.common.showApp import java.io.File import java.nio.file.* import java.nio.file.attribute.BasicFileAttributes +import java.nio.file.attribute.FileTime +import kotlin.io.path.setLastModifiedTime fun main() { initHaskell() @@ -20,6 +22,15 @@ private fun initHaskell() { val libsTmpDir = File(tmpDir.absolutePath + File.separator + "libs") copyResources(desktopPlatform.libPath, libsTmpDir.toPath()) 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() initHS() } @@ -34,7 +45,12 @@ private fun copyResources(from: String, to: Path) { return FileVisitResult.CONTINUE } 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 } }) diff --git a/scripts/desktop/build-lib-linux.sh b/scripts/desktop/build-lib-linux.sh index ab2664792..2d9681fc6 100755 --- a/scripts/desktop/build-lib-linux.sh +++ b/scripts/desktop/build-lib-linux.sh @@ -23,3 +23,4 @@ rm -rf apps/multiplatform/desktop/build/cmake 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 $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 diff --git a/scripts/desktop/build-lib-mac.sh b/scripts/desktop/build-lib-mac.sh index 1ea6e146b..50bc09df5 100755 --- a/scripts/desktop/build-lib-mac.sh +++ b/scripts/desktop/build-lib-mac.sh @@ -95,3 +95,4 @@ rm -rf apps/multiplatform/desktop/build/cmake 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 $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 diff --git a/scripts/desktop/prepare-vlc-linux.sh b/scripts/desktop/prepare-vlc-linux.sh new file mode 100755 index 000000000..e1cfa7e9f --- /dev/null +++ b/scripts/desktop/prepare-vlc-linux.sh @@ -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' {} \; diff --git a/scripts/desktop/prepare-vlc-mac.sh b/scripts/desktop/prepare-vlc-mac.sh new file mode 100755 index 000000000..69644bcc1 --- /dev/null +++ b/scripts/desktop/prepare-vlc-mac.sh @@ -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